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:
Neo
2026-05-04 02:30:19 +08:00
parent 2010034840
commit caf179a5da
130 changed files with 14543 additions and 2717 deletions

View File

@@ -25,6 +25,7 @@ import {
TeamOutlined,
BugOutlined,
ApartmentOutlined,
RobotOutlined,
} from "@ant-design/icons";
import type { MenuProps } from "antd";
import { useAuthStore } from "./store/authStore";
@@ -36,6 +37,10 @@ import EnvConfig from "./pages/EnvConfig";
import DBViewer from "./pages/DBViewer";
import TenantAdmins from "./pages/TenantAdmins";
import AIRunLogs from "./pages/AIRunLogs";
import AIDashboard from "./pages/AIDashboard";
import AIOperations from "./pages/AIOperations";
import AITriggerJobs from "./pages/AITriggerJobs";
import AIPrewarm from "./pages/AIPrewarm";
import DevTrace from "./pages/DevTrace";
import TriggerJobs from "./pages/TriggerJobs";
import TransferLog from "./pages/TransferLog";
@@ -44,6 +49,7 @@ import TaskEngineConfig from "./pages/TaskEngineConfig";
import Dashboard from "./pages/Dashboard";
import ETLTasks from "./pages/ETLTasks";
import TriggerManager from "./pages/TriggerManager";
import RuntimeContextPage from "./pages/RuntimeContext";
const { Sider, Content, Footer } = Layout;
const { Text } = Typography;
@@ -65,11 +71,22 @@ export const NAV_ITEMS: MenuProps["items"] = [
],
},
{ key: "/triggers", icon: <ClockCircleOutlined />, label: "触发器管理" },
{
key: "ai-group", icon: <RobotOutlined />, label: "AI 管理",
children: [
{ key: "/ai/dashboard", label: "总览" },
{ key: "/ai/operations", label: "手动操作" },
{ key: "/ai/prewarm", label: "预热进度" },
{ key: "/triggers?tab=ai", label: "触发器设置" },
{ key: "/ai/trigger-jobs", label: "调度历史" },
],
},
{ key: "/tenant-admins", icon: <TeamOutlined />, label: "租户管理员" },
{
key: "settings-group", icon: <SettingOutlined />, label: "系统设置",
children: [
{ key: "/settings/env-config", label: "环境配置" },
{ key: "/settings/runtime-context", label: "业务运行上下文 / 沙箱" },
{ key: "/triggers?tab=biz", label: "触发器配置" },
],
},
@@ -90,12 +107,14 @@ export const NAV_ITEMS: MenuProps["items"] = [
/** 根据当前路径计算 selectedKeys */
export function getSelectedKeys(pathname: string, search: string): string[] {
const fullPath = pathname + search;
// 精确匹配含查询参数的菜单项(如 /triggers?tab=biz
// 精确匹配含查询参数的菜单项(如 /triggers?tab=biz / ?tab=ai
if (fullPath === "/triggers?tab=biz") return ["/triggers?tab=biz"];
if (fullPath === "/triggers?tab=ai") return ["/triggers?tab=ai"];
// 子路由匹配
if (pathname.startsWith("/task-engine/")) return [pathname];
if (pathname.startsWith("/settings/")) return [pathname];
if (pathname.startsWith("/logs/")) return [pathname];
if (pathname.startsWith("/ai/")) return [pathname];
// 一级路由直接匹配
return [pathname];
}
@@ -106,6 +125,9 @@ export function getDefaultOpenKeys(pathname: string): string[] {
if (pathname.startsWith("/task-engine/")) keys.push("task-engine-group");
if (pathname.startsWith("/settings/")) keys.push("settings-group");
if (pathname.startsWith("/logs/")) keys.push("logs-group");
if (pathname.startsWith("/ai/")) keys.push("ai-group");
// 从 AI 菜单跳过来的"触发器设置"/triggers?tab=ai也展开 ai-group
// 注:此函数参数只接收 pathname无法判断 tab交由路由侧 searchParams 处理默认展开
// 触发器配置跳转入口也需要展开系统设置
if (pathname === "/triggers") keys.push("settings-group");
return keys;
@@ -225,6 +247,13 @@ const AppLayout: React.FC = () => {
{/* 系统设置 */}
<Route path="/settings/env-config" element={<EnvConfig />} />
<Route path="/settings/runtime-context" element={<RuntimeContextPage />} />
{/* AI 管理 */}
<Route path="/ai/dashboard" element={<AIDashboard />} />
<Route path="/ai/operations" element={<AIOperations />} />
<Route path="/ai/prewarm" element={<AIPrewarm />} />
<Route path="/ai/trigger-jobs" element={<AITriggerJobs />} />
{/* 日志调试 */}
<Route path="/logs/dev-trace" element={<DevTrace />} />

View File

@@ -0,0 +1,43 @@
/**
* 回归测试admin-web 手动执行 App 类型必须与后端 /api/admin/ai/run/{app_type} 对齐。
*
* 缓存类型仍使用 `*_analysis` / `*_consolidated`,但手动执行和 run log
* 应使用 dispatcher 支持的 app_type避免前端发出后端 400 的路径。
*/
import { describe, expect, it } from "vitest";
import { RUN_APP_TYPES } from "../api/adminAI";
import { CACHE_TYPE_OPTIONS, RUN_APP_TYPE_OPTIONS } from "../pages/AIOperations";
import { RUN_LOG_APP_TYPE_OPTIONS } from "../pages/AIRunLogs";
describe("admin AI app_type 对齐", () => {
it("手动执行类型使用后端支持的 app_type而不是缓存类型", () => {
const apiTypes = [...RUN_APP_TYPES];
const runOptionValues = RUN_APP_TYPE_OPTIONS.map((item) => item.value);
for (const appType of ["app6_note", "app7_customer", "app8_consolidation"]) {
expect(apiTypes).toContain(appType);
expect(runOptionValues).toContain(appType);
}
for (const cacheType of ["app6_note_analysis", "app7_customer_analysis", "app8_clue_consolidated"]) {
expect(runOptionValues).not.toContain(cacheType);
}
});
it("缓存失效继续使用 cache_type避免破坏已有缓存管理", () => {
const cacheOptionValues = CACHE_TYPE_OPTIONS.map((item) => item.value);
expect(cacheOptionValues).toContain("app6_note_analysis");
expect(cacheOptionValues).toContain("app7_customer_analysis");
expect(cacheOptionValues).toContain("app8_clue_consolidated");
});
it("调用记录筛选包含真实写入 ai_run_logs 的 app_type", () => {
const runLogOptionValues = RUN_LOG_APP_TYPE_OPTIONS.map((item) => item.value);
expect(runLogOptionValues).toContain("app6_note");
expect(runLogOptionValues).toContain("app7_customer");
expect(runLogOptionValues).toContain("app8_consolidate");
});
});

View File

@@ -9,29 +9,30 @@ import { apiClient } from "./client";
// ---- 公共类型 ----
export const RUN_APP_TYPES = [
"app2_finance",
"app2a_finance_area",
"app3_clue",
"app4_analysis",
"app5_tactics",
"app6_note",
"app7_customer",
"app8_consolidation",
] as const;
/**
* AI APP 类型联合(与后端 `CacheTypeEnum` / `_SUPPORTED_APP_TYPES` 同步)。
* 按需执行 App 类型联合(与后端 `/api/admin/ai/run/{app_type}` 同步)。
*
* - app1_chat · 小程序聊天(无缓存)
* - app2_finance · 全域财务洞察area = 'all'8 组合)
* - app2a_finance_area · 区域财务洞察area != 'all'64 组合2026-04-23 新增)
* - app3_clue · 客户线索分析
* - app4_analysis · 助教关系分析
* - app5_tactics · 话术参考
* - app6_note_analysis · 备注分析
* - app7_customer_analysis · 客户综合分析
* - app8_clue_consolidated · 线索整合
* - app6_note · 备注分析
* - app7_customer · 客户综合分析
* - app8_consolidation · 线索整合
*/
export type AppType =
| "app1_chat"
| "app2_finance"
| "app2a_finance_area"
| "app3_clue"
| "app4_analysis"
| "app5_tactics"
| "app6_note_analysis"
| "app7_customer_analysis"
| "app8_clue_consolidated";
export type AppType = (typeof RUN_APP_TYPES)[number];
// ---- 类型定义 ----

View File

@@ -0,0 +1,78 @@
/**
* 业务运行上下文 / 沙箱配置 API。
*/
import { apiClient } from "./client";
export type RuntimeMode = "live" | "sandbox";
export type AIMode = "live";
export type RuntimeStepStatus = "success" | "skipped" | "warning" | "failed";
export interface RuntimeContext {
site_id: number;
mode: RuntimeMode;
business_day_start_hour: number;
business_date: string;
business_now: string;
sandbox_date: string | null;
sandbox_instance_id: string | null;
ai_mode: AIMode;
status: string;
is_sandbox: boolean;
}
export interface RuntimeSiteItem {
site_id: number;
site_name: string | null;
site_code: string | null;
is_active: boolean;
mode: RuntimeMode | null;
sandbox_date: string | null;
sandbox_instance_id: string | null;
ai_mode: AIMode | null;
status: string | null;
updated_at: string | null;
}
export interface RuntimeSwitchRequest {
site_id: number;
mode: RuntimeMode;
sandbox_date?: string | null;
reset_sandbox?: boolean;
reason?: string | null;
}
export interface RuntimeTransitionStep {
key: string;
title: string;
status: RuntimeStepStatus;
detail: string;
count: number;
}
export interface RuntimeSwitchResponse {
context: RuntimeContext;
steps: RuntimeTransitionStep[];
}
export async function fetchRuntimeSites(): Promise<RuntimeSiteItem[]> {
const { data } = await apiClient.get<RuntimeSiteItem[]>("/admin/runtime-context/sites");
return data;
}
export async function fetchRuntimeContext(siteId: number): Promise<RuntimeContext> {
const { data } = await apiClient.get<RuntimeContext>("/admin/runtime-context", {
params: { site_id: siteId },
});
return data;
}
export async function switchRuntimeContext(
payload: RuntimeSwitchRequest,
): Promise<RuntimeSwitchResponse> {
const { data } = await apiClient.patch<RuntimeSwitchResponse>(
"/admin/runtime-context",
payload,
);
return data;
}

View File

@@ -8,12 +8,27 @@
* - 第四行:告警列表
*/
import React, { useEffect, useState, useCallback } from "react";
import React, { useEffect, useRef, useState, useCallback } from "react";
import {
Card, Row, Col, Statistic, Table, Tag, Badge, Progress,
Select, Button, message, Typography, Space,
Select, Button, message, Typography, Space, DatePicker,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import { ReloadOutlined, WifiOutlined } from "@ant-design/icons";
import type { Dayjs } from "dayjs";
const { RangePicker } = DatePicker;
const RANGE_OPTIONS = [
{ label: "今日", value: 1 },
{ label: "近 3 天", value: 3 },
{ label: "近 7 天", value: 7 },
{ label: "近 10 天", value: 10 },
{ label: "指定日期", value: 0 }, // 0 = 启用 RangePicker
];
const RANGE_LABEL: Record<number, string> = {
1: "今日", 3: "近 3 天", 7: "近 7 天", 10: "近 10 天",
};
import type { ColumnsType } from "antd/es/table";
import {
getDashboard,
@@ -85,51 +100,119 @@ const AIDashboard: React.FC = () => {
const [data, setData] = useState<DashboardResponse | null>(null);
const [loading, setLoading] = useState(false);
const [siteId, setSiteId] = useState<number | undefined>(undefined);
const [rangeDays, setRangeDays] = useState<number>(1); // 0=自定义日期 / 1/3/7/10
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
const [wsStatus, setWsStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected");
const [realtimeAlerts, setRealtimeAlerts] = useState<AlertItem[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getDashboard(siteId);
const query: { site_id?: number; range_days?: number; date_from?: string; date_to?: string } = {};
if (siteId != null) query.site_id = siteId;
if (rangeDays === 0 && customRange) {
query.date_from = customRange[0].format("YYYY-MM-DD");
query.date_to = customRange[1].format("YYYY-MM-DD");
} else if (rangeDays > 0) {
query.range_days = rangeDays;
}
const res = await getDashboard(query);
setData(res);
} catch {
message.error("加载 Dashboard 失败");
} finally {
setLoading(false);
}
}, [siteId]);
}, [siteId, rangeDays, customRange]);
useEffect(() => { load(); }, [load]);
const statLabel = rangeDays === 0
? (customRange ? `${customRange[0].format("MM-DD")} ~ ${customRange[1].format("MM-DD")}` : "指定日期")
: (RANGE_LABEL[rangeDays] || "今日");
// WebSocket 实时告警订阅
useEffect(() => {
const wsKey = siteId ?? -1;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${window.location.host}/ws/ai-alerts/${wsKey}`;
setWsStatus("connecting");
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => setWsStatus("connected");
ws.onclose = () => setWsStatus("disconnected");
ws.onerror = () => setWsStatus("disconnected");
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data as string) as {
type: string;
payload: AlertItem;
};
if (msg.type === "alert_created" && msg.payload) {
setRealtimeAlerts((prev) => [msg.payload, ...prev].slice(0, 20));
message.warning(`[实时] ${msg.payload.app_type} ${msg.payload.status}`);
}
} catch {
// 忽略非 JSON 消息
}
};
return () => {
ws.close();
wsRef.current = null;
setWsStatus("disconnected");
};
}, [siteId]);
return (
<div>
{/* 顶部:门店筛选 + 刷新 */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Space>
<Space wrap>
<Title level={4} style={{ margin: 0 }}>AI </Title>
<Select
allowClear placeholder="门店筛选" style={{ width: 200 }}
allowClear placeholder="门店筛选" style={{ width: 180 }}
value={siteId} onChange={(v) => setSiteId(v)}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<Select
value={rangeDays} onChange={setRangeDays} style={{ width: 140 }}
options={RANGE_OPTIONS}
/>
{rangeDays === 0 && (
<RangePicker
value={customRange}
onChange={(v) => setCustomRange(v as [Dayjs, Dayjs] | null)}
/>
)}
</Space>
<Space>
<Badge
status={wsStatus === "connected" ? "success" : wsStatus === "connecting" ? "processing" : "default"}
text={<span style={{ fontSize: 12, color: "#888" }}><WifiOutlined /> {wsStatus === "connected" ? "已连接" : wsStatus === "connecting" ? "连接中" : "断开"}</span>}
/>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Space>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Row>
{/* 第一行4 个统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card><Statistic title="今日调用次数" value={data?.today_calls ?? 0} /></Card>
<Card><Statistic title={`${statLabel}调用次数`} value={data?.today_calls ?? 0} /></Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="今日成功率" suffix="%"
title={`${statLabel}成功率`} suffix="%"
value={data ? (data.today_success_rate * 100).toFixed(1) : "0.0"}
/>
</Card>
</Col>
<Col span={6}>
<Card><Statistic title="今日 Token 消耗" value={data?.today_tokens ?? 0} /></Card>
<Card><Statistic title={`${statLabel} Token 消耗`} value={data?.today_tokens ?? 0} /></Card>
</Col>
<Col span={6}>
<Card>
@@ -201,11 +284,22 @@ const AIDashboard: React.FC = () => {
</Col>
</Row>
{/* 第四行:告警列表 */}
<Card title="告警列表" size="small">
{/* 第四行:告警列表(实时 + 历史合并) */}
<Card
title="告警列表"
size="small"
extra={realtimeAlerts.length > 0 && (
<Tag color="orange">{realtimeAlerts.length} </Tag>
)}
>
<Table<AlertItem>
columns={alertColumns}
dataSource={data?.recent_alerts ?? []}
dataSource={[
...realtimeAlerts,
...(data?.recent_alerts ?? []).filter(
(a) => !realtimeAlerts.some((r) => r.id === a.id)
),
]}
rowKey="id" size="small" pagination={{ pageSize: 10 }}
loading={loading}
/>

View File

@@ -31,7 +31,7 @@ const EVENT_TYPE_OPTIONS = [
const { TextArea } = Input;
const { Title } = Typography;
const APP_TYPE_OPTIONS = [
export const CACHE_TYPE_OPTIONS = [
{ label: "App3 维客线索", value: "app3_clue" },
{ label: "App4 关系分析", value: "app4_analysis" },
{ label: "App5 话术参考", value: "app5_tactics" },
@@ -40,6 +40,15 @@ const APP_TYPE_OPTIONS = [
{ label: "App8 线索整理", value: "app8_clue_consolidated" },
];
export const RUN_APP_TYPE_OPTIONS: { label: string; value: AppType }[] = [
{ label: "App3 维客线索", value: "app3_clue" },
{ label: "App4 关系分析", value: "app4_analysis" },
{ label: "App5 话术参考", value: "app5_tactics" },
{ label: "App6 备注分析", value: "app6_note" },
{ label: "App7 客户分析", value: "app7_customer" },
{ label: "App8 线索整理", value: "app8_consolidation" },
];
const ALERT_STATUS_COLOR: Record<string, string> = {
failed: "red", timeout: "orange", circuit_open: "volcano",
};
@@ -160,7 +169,7 @@ const AIOperations: React.FC = () => {
};
// ---- Card 3: 批量执行 ----
const [batchAppTypes, setBatchAppTypes] = useState<string[]>([]);
const [batchAppTypes, setBatchAppTypes] = useState<AppType[]>([]);
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
const [batchSiteId, setBatchSiteId] = useState<number>(2790685415443269);
const [batchLoading, setBatchLoading] = useState(false);
@@ -294,7 +303,7 @@ const AIOperations: React.FC = () => {
<Select
allowClear placeholder="App 类型(可选)" style={{ width: "100%" }}
value={cacheAppType} onChange={setCacheAppType}
options={APP_TYPE_OPTIONS}
options={CACHE_TYPE_OPTIONS}
/>
<Input
placeholder="会员 ID可选" value={cacheMemberId}
@@ -321,7 +330,7 @@ const AIOperations: React.FC = () => {
<Select
allowClear placeholder="App 类型" style={{ width: "100%" }}
value={runAppType} onChange={setRunAppType}
options={APP_TYPE_OPTIONS}
options={RUN_APP_TYPE_OPTIONS}
/>
</Col>
<Col span={6}>
@@ -403,9 +412,9 @@ const AIOperations: React.FC = () => {
<Col span={8}>
<div style={{ marginBottom: 8, fontWeight: 500 }}> App</div>
<Checkbox.Group
options={APP_TYPE_OPTIONS}
options={RUN_APP_TYPE_OPTIONS}
value={batchAppTypes}
onChange={(v) => setBatchAppTypes(v as string[])}
onChange={(v) => setBatchAppTypes(v as AppType[])}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
/>
</Col>

View File

@@ -85,11 +85,7 @@ const AIRunLogs: React.FC = () => {
}
};
const APP_TYPE_OPTIONS = [
"app1_chat", "app2_finance", "app3_clue", "app4_analysis",
"app5_tactics", "app6_note_analysis", "app7_customer_analysis",
"app8_clue_consolidated",
].map((v) => ({ label: v, value: v }));
const APP_TYPE_OPTIONS = RUN_LOG_APP_TYPE_OPTIONS;
const columns: ColumnsType<RunLogItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
@@ -227,3 +223,9 @@ const AIRunLogs: React.FC = () => {
};
export default AIRunLogs;
export const RUN_LOG_APP_TYPE_OPTIONS = [
"app1_chat", "app2_finance", "app2a_finance_area", "app3_clue",
"app4_analysis", "app5_tactics", "app6_note", "app7_customer",
"app8_consolidate", "app8_consolidation",
].map((v) => ({ label: v, value: v }));

View File

@@ -0,0 +1,243 @@
/**
* AI 触发器设置页面。
*
* 管理 biz.trigger_jobs 表中 job_type='ai_*' 的所有触发器,支持:
* - 启用/禁用
* - 修改 cron 表达式(仅 cron 类型)
* - 修改描述
* - 查看事件名、最近运行、下次运行、最后错误
*/
import React, { useCallback, useEffect, useState } from "react";
import {
Card, Table, Tag, Button, Space, Modal, Input, Switch,
message, Typography, Tooltip, Descriptions,
} from "antd";
import { ReloadOutlined, EditOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
listTriggers, updateTrigger,
type TriggerItem,
} from "../api/adminAI";
const { Title, Paragraph } = Typography;
const STATUS_COLOR: Record<string, string> = {
enabled: "success",
disabled: "default",
};
const CONDITION_COLOR: Record<string, string> = {
event: "processing",
cron: "warning",
interval: "cyan",
};
function fmtTime(raw: string | null): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
function cronExpr(item: TriggerItem): string {
const cfg = item.trigger_config || {};
return String(cfg.cron_expression || cfg.event_name || "—");
}
const AITriggers: React.FC = () => {
const [items, setItems] = useState<TriggerItem[]>([]);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState<TriggerItem | null>(null);
const [editCron, setEditCron] = useState("");
const [editDesc, setEditDesc] = useState("");
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await listTriggers();
setItems(res);
} catch {
message.error("加载触发器列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleToggle = async (item: TriggerItem, next: boolean) => {
try {
await updateTrigger(item.id, { status: next ? "enabled" : "disabled" });
message.success(next ? "已启用" : "已禁用");
load();
} catch {
message.error("状态切换失败");
}
};
const openEdit = (item: TriggerItem) => {
setEditing(item);
setEditCron(String(item.trigger_config?.cron_expression || ""));
setEditDesc(item.description || "");
};
const handleSave = async () => {
if (!editing) return;
setSaving(true);
try {
const body: { cron_expression?: string; description?: string } = {};
if (editing.trigger_condition === "cron" && editCron !== editing.trigger_config?.cron_expression) {
body.cron_expression = editCron;
}
if (editDesc !== (editing.description || "")) {
body.description = editDesc;
}
if (Object.keys(body).length === 0) {
message.info("无变更");
setEditing(null);
return;
}
await updateTrigger(editing.id, body);
message.success("已保存");
setEditing(null);
load();
} catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
message.error(`保存失败${msg ? `${msg}` : ""}`);
} finally {
setSaving(false);
}
};
const columns: ColumnsType<TriggerItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 60 },
{
title: "触发器名", dataIndex: "job_name", key: "job_name", width: 200,
render: (v: string, r) => (
<div>
<div style={{ fontWeight: 500 }}>{v}</div>
{r.description && (
<div style={{ fontSize: 12, color: "#888", marginTop: 2 }}>{r.description}</div>
)}
</div>
),
},
{
title: "类型", dataIndex: "trigger_condition", key: "trigger_condition", width: 80,
render: (v: string) => <Tag color={CONDITION_COLOR[v] ?? "default"}>{v}</Tag>,
},
{
title: "表达式 / 事件", key: "expr", width: 240,
render: (_: unknown, r) => (
<code style={{ fontSize: 12 }}>{cronExpr(r)}</code>
),
},
{
title: "状态", dataIndex: "status", key: "status", width: 100,
render: (v: string, r) => (
<Space>
<Switch
size="small"
checked={v === "enabled"}
onChange={(c) => handleToggle(r, c)}
/>
<Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>
</Space>
),
},
{ title: "最近运行", dataIndex: "last_run_at", key: "last_run_at", width: 160, render: fmtTime },
{ title: "下次运行", dataIndex: "next_run_at", key: "next_run_at", width: 160, render: fmtTime },
{
title: "最后错误", dataIndex: "last_error", key: "last_error", ellipsis: true,
render: (v: string | null) => v ? (
<Tooltip title={v}><span style={{ color: "#d46b08" }}>{v.slice(0, 40)}</span></Tooltip>
) : "—",
},
{
title: "操作", key: "action", width: 100, fixed: "right",
render: (_: unknown, r) => (
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
),
},
];
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div>
<Title level={4} style={{ margin: 0 }}>AI </Title>
<Paragraph type="secondary" style={{ margin: 0, fontSize: 13 }}>
<code>biz.trigger_jobs</code> AI cron
</Paragraph>
</div>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</div>
<Card size="small">
<Table<TriggerItem>
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1200 }}
/>
</Card>
<Modal
title={editing ? `编辑触发器 #${editing.id}` : ""}
open={!!editing}
onCancel={() => setEditing(null)}
onOk={handleSave}
confirmLoading={saving}
okText="保存" cancelText="取消"
width={600}
>
{editing && (
<>
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="触发器名">{editing.job_name}</Descriptions.Item>
<Descriptions.Item label="类型">
<Tag color={CONDITION_COLOR[editing.trigger_condition] ?? "default"}>
{editing.trigger_condition}
</Tag>
</Descriptions.Item>
{editing.trigger_condition === "event" && (
<Descriptions.Item label="事件名">
<code>{String(editing.trigger_config?.event_name || "—")}</code>
</Descriptions.Item>
)}
</Descriptions>
{editing.trigger_condition === "cron" && (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 4, fontWeight: 500 }}>Cron </div>
<Input
value={editCron}
onChange={(e) => setEditCron(e.target.value)}
placeholder="标准 5 段 cron例如 0 10 * * *"
/>
<Paragraph type="secondary" style={{ fontSize: 12, margin: "4px 0 0 0" }}>
<code> </code><code>0 10 * * *</code> 10:00<code>*/30 * * * *</code> 30
</Paragraph>
</div>
)}
<div>
<div style={{ marginBottom: 4, fontWeight: 500 }}></div>
<Input.TextArea
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
rows={3}
/>
</div>
</>
)}
</Modal>
</div>
);
};
export default AITriggers;

View File

@@ -0,0 +1,335 @@
/**
* 业务运行上下文 / 沙箱设置页面。
*
* 仅限超级管理员:列出门店当前模式,支持切换到 sandbox 指定历史日期或切回 live。
* 切换会按 site_id 暂停或恢复 biz.trigger_jobs确保多门店隔离。
*/
import React, { useEffect, useMemo, useState, useCallback } from "react";
import {
Alert, Button, Card, DatePicker, Form, Input, Modal, Popconfirm, Space,
Switch, Table, Tag, Tooltip, Typography, message,
} from "antd";
import { ReloadOutlined, SwapOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
import {
fetchRuntimeSites, switchRuntimeContext,
type RuntimeSiteItem, type RuntimeMode, type RuntimeTransitionStep,
} from "../api/runtimeContext";
import { useAuthStore } from "../store/authStore";
const { Title, Text } = Typography;
interface SwitchFormValues {
mode: RuntimeMode;
sandbox_date: Dayjs | null;
reset_sandbox: boolean;
reason: string;
}
const stepStatusColor: Record<RuntimeTransitionStep["status"], string> = {
success: "green",
skipped: "default",
warning: "orange",
failed: "red",
};
const RuntimeContextPage: React.FC = () => {
const user = useAuthStore((s) => s.user);
const isSuperAdmin = user?.roles?.includes("super_admin") ?? false;
const [sites, setSites] = useState<RuntimeSiteItem[]>([]);
const [loading, setLoading] = useState(false);
const [switchTarget, setSwitchTarget] = useState<RuntimeSiteItem | null>(null);
const [submitting, setSubmitting] = useState(false);
const [stepsModal, setStepsModal] = useState<{ siteName: string; steps: RuntimeTransitionStep[] } | null>(null);
const [form] = Form.useForm<SwitchFormValues>();
const load = useCallback(async () => {
setLoading(true);
try {
const data = await fetchRuntimeSites();
setSites(data);
} catch {
message.error("加载门店运行上下文失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isSuperAdmin) {
load();
}
}, [isSuperAdmin, load]);
const openSwitch = (record: RuntimeSiteItem, mode: RuntimeMode) => {
setSwitchTarget(record);
form.resetFields();
form.setFieldsValue({
mode,
sandbox_date: record.sandbox_date ? dayjs(record.sandbox_date) : null,
reset_sandbox: true,
reason: "",
});
};
const handleSubmit = async () => {
if (!switchTarget) return;
let values: SwitchFormValues;
try {
values = await form.validateFields();
} catch {
return;
}
setSubmitting(true);
try {
const resp = await switchRuntimeContext({
site_id: switchTarget.site_id,
mode: values.mode,
sandbox_date: values.mode === "sandbox" ? values.sandbox_date?.format("YYYY-MM-DD") : null,
reset_sandbox: values.mode === "sandbox" ? values.reset_sandbox : true,
reason: values.reason || null,
});
message.success(values.mode === "sandbox" ? "已切换为沙箱模式" : "已切回 live 模式");
setStepsModal({
siteName: switchTarget.site_name || `#${switchTarget.site_id}`,
steps: resp.steps,
});
setSwitchTarget(null);
form.resetFields();
load();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
message.error(msg || "切换失败");
} finally {
setSubmitting(false);
}
};
const columns: ColumnsType<RuntimeSiteItem> = useMemo(
() => [
{
title: "门店",
key: "site",
width: 200,
render: (_: unknown, r) => (
<Space direction="vertical" size={0}>
<Text strong>{r.site_name || `#${r.site_id}`}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.site_code ? `${r.site_code} · ` : ""}site_id={r.site_id}
</Text>
</Space>
),
},
{
title: "运行模式",
key: "mode",
width: 140,
render: (_: unknown, r) => {
const mode = (r.mode ?? "live") as RuntimeMode;
return mode === "sandbox" ? (
<Tag color="purple"></Tag>
) : (
<Tag color="blue"> live</Tag>
);
},
},
{
title: "业务日期",
key: "business_date",
width: 160,
render: (_: unknown, r) =>
r.mode === "sandbox" && r.sandbox_date ? (
<Tooltip title="沙箱模拟的业务日期">
<Tag color="purple">{r.sandbox_date}</Tag>
</Tooltip>
) : (
<Text type="secondary"></Text>
),
},
{
title: "沙箱实例",
dataIndex: "sandbox_instance_id",
key: "sandbox_instance_id",
width: 240,
render: (v: string | null) => (v ? <Text code>{v}</Text> : "—"),
},
{
title: "AI 模式",
dataIndex: "ai_mode",
key: "ai_mode",
width: 100,
render: (v: string | null) => v ?? "live",
},
{
title: "更新时间",
dataIndex: "updated_at",
key: "updated_at",
width: 170,
render: (v: string | null) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm") : "—"),
},
{
title: "操作",
key: "action",
fixed: "right",
width: 220,
render: (_: unknown, r) => {
const mode = (r.mode ?? "live") as RuntimeMode;
if (mode === "sandbox") {
return (
<Space>
<Button size="small" icon={<SwapOutlined />} onClick={() => openSwitch(r, "sandbox")}>
</Button>
<Popconfirm
title="确认切回 live 模式?"
description="将恢复该门店触发器并清理沙箱状态。"
okText="切回 live"
cancelText="取消"
onConfirm={() => openSwitch(r, "live")}
>
<Button size="small" danger>
live
</Button>
</Popconfirm>
</Space>
);
}
return (
<Button size="small" type="primary" icon={<SwapOutlined />} onClick={() => openSwitch(r, "sandbox")}>
</Button>
);
},
},
],
[form],
);
if (!isSuperAdmin) {
return (
<Alert
type="warning"
showIcon
message="无权限"
description="业务运行上下文/沙箱设置仅对超级管理员开放。"
/>
);
}
const target = switchTarget;
const targetMode = Form.useWatch("mode", form) ?? "live";
return (
<Card
title={<Title level={4} style={{ margin: 0 }}> / </Title>}
extra={
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
</Button>
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="按门店隔离的业务时钟"
description={
<Space direction="vertical" size={2}>
<Text>live 使</Text>
<Text>
sandbox sandbox_date sandbox_instance_id
ETL AI biz.trigger_jobs
</Text>
<Text type="secondary">
AI tokens
</Text>
<Text type="warning">
/ / / AI sandbox_date
<Text code>docs/database/changes/2026-05-02__sandbox_no_future_data_plan.md</Text>
</Text>
</Space>
}
/>
<Table
rowKey="site_id"
loading={loading}
dataSource={sites}
columns={columns}
size="middle"
pagination={false}
scroll={{ x: 1100 }}
/>
<Modal
open={!!target}
title={target ? `切换运行上下文 — ${target.site_name || `#${target.site_id}`}` : ""}
onCancel={() => {
if (!submitting) {
setSwitchTarget(null);
form.resetFields();
}
}}
onOk={handleSubmit}
okText="确认切换"
cancelText="取消"
confirmLoading={submitting}
destroyOnClose
width={640}
>
<Form
layout="vertical"
form={form}
initialValues={{ mode: "sandbox", reset_sandbox: true, reason: "" }}
>
<Form.Item label="目标模式" name="mode" rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
{targetMode === "sandbox" && (
<>
<Form.Item
label="沙箱业务日期"
name="sandbox_date"
rules={[{ required: true, message: "沙箱模式需要选择历史业务日期" }]}
>
<DatePicker
style={{ width: "100%" }}
disabledDate={(d) => d && d.isAfter(dayjs(), "day")}
/>
</Form.Item>
<Form.Item label="重置沙箱实例" name="reset_sandbox" valuePropName="checked">
<Switch checkedChildren="新实例" unCheckedChildren="沿用原实例" />
</Form.Item>
</>
)}
<Form.Item label="操作原因(可选)" name="reason">
<Input.TextArea rows={2} maxLength={500} showCount placeholder="例如:演示按 2026-03-15 重放任务分发" />
</Form.Item>
</Form>
</Modal>
<Modal
open={!!stepsModal}
title={stepsModal ? `切换执行结果 — ${stepsModal.siteName}` : ""}
onCancel={() => setStepsModal(null)}
onOk={() => setStepsModal(null)}
okText="知道了"
cancelButtonProps={{ style: { display: "none" } }}
width={640}
destroyOnClose
>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
{(stepsModal?.steps ?? []).map((s) => (
<div key={s.key}>
<Tag color={stepStatusColor[s.status]}>{s.title}</Tag>
{s.count ? <Text type="secondary"> {s.count} </Text> : null}
<Text style={{ marginLeft: 8 }}>{s.detail}</Text>
</div>
))}
</Space>
</Modal>
</Card>
);
};
export default RuntimeContextPage;

View File

@@ -6,7 +6,7 @@
* - destroyInactiveTabPane={false} 保持 Tab 状态不丢失
* - "全部"Tab 调用 fetchUnifiedTriggers(),展示统一字段表格
* - "业务"Tab 复用 TriggerJobs 组件 + 编辑 Modal
* - "AI"Tab 复用 AIOperations + AITriggerJobs 组件
* - "AI"Tab 复用 AITriggers触发器设置+ AIOperations + AITriggerJobs 组件
* - "ETL"Tab 展示 scheduled_tasks 数据
*
* CHANGE 2026-07-15 | Task 10.1:创建 TriggerManager 页面
@@ -37,6 +37,7 @@ import { fetchSchedules } from '../api/schedules';
import type { ScheduledTask } from '../types';
import AIOperations from './AIOperations';
import AITriggerJobs from './AITriggerJobs';
import AITriggers from './AITriggers';
const { Title } = Typography;
@@ -319,6 +320,7 @@ const BizTriggersTab: React.FC = () => {
const AITriggersTab: React.FC = () => (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<AITriggers />
<AIOperations />
<AITriggerJobs />
</Space>

View File

@@ -1 +0,0 @@
# AI 应用子模块app1_chat ~ app8_consolidation

View File

@@ -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 messageyield 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_pagecontextType调用 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

View File

@@ -1,210 +0,0 @@
"""应用 2财务洞察。
8 个时间维度独立调用,每次调用结果写入 ai_cache
同时创建 ai_conversations + ai_messages 记录。
营业日分界点:每日 08:00BUSINESS_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:
百炼返回的结构化 JSONinsights 数组)
"""
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

View File

@@ -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
# 构建 referenceApp6 线索 + 最近 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:
百炼返回的结构化 JSONclues 数组)
"""
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

View File

@@ -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]
# 构建 referenceApp8 最新 + 最近 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:
百炼返回的结构化 JSONtask_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

View File

@@ -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:
百炼返回的结构化 JSONtactics 数组)
"""
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

View File

@@ -1,289 +0,0 @@
"""应用 6备注分析骨架
助教提交备注后自动触发,通过 AI 分析备注内容,
提取维客线索并评分。
返回 score1-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]
# 构建 referenceApp3 线索 + 最近 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"
"返回 score1-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:
百炼返回的结构化 JSONscore + 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

View File

@@ -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:
百炼返回的结构化 JSONstrategies 数组 + 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

View File

@@ -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 值。
- 纯 App3providers 仅含"系统")→ ai_consumption
- 纯 App6providers 不含"系统")→ 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:
百炼返回的结构化 JSONclues 数组)
"""
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

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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和桌台IDsite_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:

View File

@@ -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()

View File

@@ -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)

View File

@@ -0,0 +1,123 @@
"""AI 事件广播总线in-process pub/sub
支持按 site_id 订阅的异步事件分发,用于:
- Phase 1.4AI 缓存主动失效 / 更新通知 → admin-web、小程序刷新
- Phase 3.1AI 告警实时推送(告警发生 / 确认 / 忽略)
设计要点:
- 仿 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

View File

@@ -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_amountsettle_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. 核心 KPIAI 洞察首要依据
"核心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)

View File

@@ -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),

View File

@@ -0,0 +1,131 @@
"""应用 3 客户数据维客线索分析 Prompt 拼装。
消费事件触发,从客户消费数据提取维客线索。
- 数据源fetch_member_consumption_dataDWS
- 金额口径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

View 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

View 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

View File

@@ -0,0 +1,160 @@
"""应用 6 备注分析 Prompt 拼装。
助教提交备注后触发AI 分析备注内容并评分1-10+ 提取维客线索。
- 数据源context.note_content + fetch_member_consumption_data + fetch_member_notes
- 线索 category6 选 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

View 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

View File

@@ -1,93 +1,52 @@
"""应用 8维客线索整理 Prompt 模板
"""应用 8 维客线索整理 Prompt 拼装
接收 App3消费分析和 App6备注分析的全部线索
整合去重后输出统一维客线索。
- 数据源context.app3_clues + context.app6_cluesdispatcher 已查好传入)
- 分类标签 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)

View 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:
"""为 App1chatassistant 消息构建单个 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)

View File

@@ -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-222000→8000。app2_finance 真实 prompt 约 4-8KB72 组合财务看板 + 中文 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"

View File

@@ -36,11 +36,12 @@ from app import config
# CHANGE 2026-03-23 | 新增 trigger_jobs 路由(定时任务管理页面 API
# CHANGE 2026-03-24 | P18 任务引擎运营看板:新增 admin_task_engine 路由
# CHANGE 2026-03-29 | DWS_TASK_ENGINE新增 internal_events 路由(按 job_name 执行任务)
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, xcx_avatar, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config, tenant_auth, tenant_users, tenant_excel, tenant_clues, tenant_site_admins, admin_tenant_admins, admin_registry, internal_ai, admin_ai, admin_dev_trace, trigger_jobs, admin_task_engine, admin_db_health, admin_triggers, internal_events
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, xcx_avatar, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config, xcx_runtime_clock, tenant_auth, tenant_users, tenant_excel, tenant_clues, tenant_site_admins, admin_tenant_admins, admin_registry, internal_ai, admin_ai, admin_dev_trace, trigger_jobs, admin_task_engine, admin_db_health, admin_triggers, internal_events, admin_runtime_context
from app.services.scheduler import scheduler
from app.services.task_queue import task_queue
from app.services.task_executor import task_executor
from app.ws.logs import ws_router
from app.ws.ai_events import ws_router as ai_ws_router
@asynccontextmanager
@@ -99,6 +100,33 @@ async def lifespan(app: FastAPI):
import logging as _log
_log.getLogger(__name__).warning("启动检查定时任务失败", exc_info=True)
# CHANGE 2026-04-22 | 启动时清理上次进程遗留的孤儿 run_logsworker 被 kill/reload 导致 status 卡在 running
try:
from app.database import get_connection as _get_conn_cleanup
_c = _get_conn_cleanup()
try:
with _c.cursor() as _cur:
_cur.execute(
"""
UPDATE biz.ai_run_logs
SET status = 'failed',
error_message = COALESCE(error_message, '') || ' [orphaned_by_restart]',
finished_at = COALESCE(finished_at, NOW())
WHERE status = 'running'
AND created_at < NOW() - INTERVAL '5 minutes'
"""
)
_cleaned = _cur.rowcount
_c.commit()
if _cleaned:
import logging as _log
_log.getLogger(__name__).info("启动清理 %d 条孤儿 run_logsstatus=running > 5min", _cleaned)
finally:
_c.close()
except Exception:
import logging as _log
_log.getLogger(__name__).warning("孤儿 run_logs 清理失败(忽略)", exc_info=True)
# CHANGE 2026-03-10 | 注册 AI 事件处理器(消费/备注/任务分配 → AI 调用链)
# CHANGE 2026-03-22 | P14 迁移BailianClient → DashScopeClient + AIConfig + 防护层
try:
@@ -127,6 +155,8 @@ async def lifespan(app: FastAPI):
config=_ai_config,
)
register_ai_handlers(_dispatcher)
from app.routers import internal_ai as _internal_ai_router
_internal_ai_router.set_dispatcher(_dispatcher)
except Exception:
import logging as _log
_log.getLogger(__name__).warning("AI 事件处理器注册失败AI 功能不可用", exc_info=True)
@@ -178,6 +208,7 @@ app.include_router(env_config.router)
app.include_router(db_viewer.router)
app.include_router(etl_status.router)
app.include_router(ws_router)
app.include_router(ai_ws_router)
app.include_router(xcx_test.router)
app.include_router(wx_callback.router)
app.include_router(member_retention_clue.router)
@@ -195,6 +226,7 @@ app.include_router(xcx_customers.router)
app.include_router(xcx_coaches.router)
app.include_router(xcx_board.router)
app.include_router(xcx_config.router)
app.include_router(xcx_runtime_clock.router)
app.include_router(tenant_auth.router)
app.include_router(tenant_users.router)
app.include_router(tenant_excel.router)
@@ -210,6 +242,8 @@ app.include_router(trigger_jobs.router)
app.include_router(admin_task_engine.router)
app.include_router(admin_db_health.router)
app.include_router(admin_triggers.router)
app.include_router(admin_runtime_context.router)
app.include_router(admin_runtime_context.config_router)
@app.get("/health", tags=["系统"])

View File

@@ -0,0 +1,309 @@
# -*- coding: utf-8 -*-
"""业务运行上下文管理 API。"""
from __future__ import annotations
import logging
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from psycopg2.extras import RealDictCursor
from app.auth.dependencies import CurrentUser, get_current_user
from app.database import get_connection
from app.schemas.runtime_context import (
RuntimeContextResponse,
RuntimeSwitchRequest,
RuntimeSwitchResponse,
RuntimeTransitionStep,
)
from app.services.runtime_context import (
MODE_LIVE,
MODE_SANDBOX,
RuntimeContext,
get_runtime_context,
new_sandbox_instance_id,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/runtime-context", tags=["业务运行上下文"])
config_router = APIRouter(prefix="/api/config", tags=["业务配置"])
def _require_super_admin(user: CurrentUser) -> None:
if "super_admin" not in user.roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅超级管理员可切换业务运行上下文",
)
def _context_response(ctx: RuntimeContext) -> RuntimeContextResponse:
return RuntimeContextResponse(**ctx.to_dict())
@config_router.get("/runtime-context", response_model=RuntimeContextResponse)
async def get_current_runtime_context(
user: CurrentUser = Depends(get_current_user),
) -> RuntimeContextResponse:
"""返回当前登录用户门店的业务运行上下文。"""
return _context_response(get_runtime_context(user.site_id))
@router.get("", response_model=RuntimeContextResponse)
async def get_admin_runtime_context(
site_id: int = Query(..., ge=1),
user: CurrentUser = Depends(get_current_user),
) -> RuntimeContextResponse:
"""系统管理端按门店查看业务运行上下文。"""
_require_super_admin(user)
return _context_response(get_runtime_context(site_id))
@router.get("/sites")
async def list_runtime_sites(
user: CurrentUser = Depends(get_current_user),
) -> list[dict]:
"""列出可配置门店及其当前运行上下文。"""
_require_super_admin(user)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT s.site_id, s.site_name, s.site_code, s.is_active,
c.mode, c.sandbox_date, c.sandbox_instance_id,
c.ai_mode, c.status, c.updated_at
FROM biz.sites s
LEFT JOIN biz.site_runtime_context c ON c.site_id = s.site_id
ORDER BY s.is_active DESC, s.site_id
"""
)
rows = cur.fetchall()
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
return [dict(row) for row in rows]
@router.patch("", response_model=RuntimeSwitchResponse)
async def switch_runtime_context(
body: RuntimeSwitchRequest,
user: CurrentUser = Depends(get_current_user),
) -> RuntimeSwitchResponse:
"""切换门店业务运行上下文。
切换前会终止当前运行中的 ETL、取消未完成 AI 触发记录。
`biz.trigger_jobs` 是全局调度表(无 site_id 列),不随单门店沙箱切换暂停;
多门店隔离完全通过 runtime_mode + sandbox_instance_id 实现。
"""
_require_super_admin(user)
if body.mode == MODE_SANDBOX and body.sandbox_date is None:
raise HTTPException(status_code=422, detail="沙箱模式必须设置 sandbox_date")
if body.mode == MODE_LIVE and body.sandbox_date is not None:
raise HTTPException(status_code=422, detail="live 模式不能设置 sandbox_date")
if body.mode == MODE_SANDBOX and body.sandbox_date and body.sandbox_date > date.today():
raise HTTPException(status_code=422, detail="sandbox_date 不能晚于真实今天")
steps: list[RuntimeTransitionStep] = []
steps.extend(await _stop_runtime_activity(body.site_id))
conn = get_connection()
try:
with conn.cursor() as cur:
old_ctx = get_runtime_context(body.site_id, conn=conn)
sandbox_instance_id = None
if body.mode == MODE_SANDBOX:
if body.reset_sandbox or not old_ctx.sandbox_instance_id:
sandbox_instance_id = new_sandbox_instance_id()
else:
sandbox_instance_id = old_ctx.sandbox_instance_id
cur.execute(
"""
INSERT INTO biz.site_runtime_context
(site_id, mode, sandbox_date, sandbox_instance_id, ai_mode,
status, updated_by, updated_at, reason)
VALUES (%s, %s, %s, %s, 'live', 'active', %s, NOW(), %s)
ON CONFLICT (site_id)
DO UPDATE SET
mode = EXCLUDED.mode,
sandbox_date = EXCLUDED.sandbox_date,
sandbox_instance_id = EXCLUDED.sandbox_instance_id,
ai_mode = EXCLUDED.ai_mode,
status = EXCLUDED.status,
updated_by = EXCLUDED.updated_by,
updated_at = NOW(),
reason = EXCLUDED.reason
""",
(
body.site_id,
body.mode,
body.sandbox_date,
sandbox_instance_id,
user.user_id,
body.reason,
),
)
steps.append(RuntimeTransitionStep(
key="biz_triggers_unchanged",
title="保持业务触发器",
status="skipped",
count=0,
detail=(
"biz.trigger_jobs 为全局调度表(无 site_id 列),单门店沙箱切换不影响其它门店;"
"沙箱隔离由 runtime_mode + sandbox_instance_id 在数据写入层完成。"
),
))
conn.commit()
except Exception:
conn.rollback()
logger.exception("切换业务运行上下文失败: site_id=%s", body.site_id)
raise
finally:
conn.close()
ctx = get_runtime_context(body.site_id)
steps.append(RuntimeTransitionStep(
key="apply_context",
title="写入业务运行上下文",
status="success",
detail=(
f"当前模式={ctx.mode},业务日期={ctx.business_date}"
+ (f",沙箱实例={ctx.sandbox_instance_id}" if ctx.is_sandbox else "")
),
))
return RuntimeSwitchResponse(context=_context_response(ctx), steps=steps)
async def _stop_runtime_activity(site_id: int) -> list[RuntimeTransitionStep]:
"""终止切换前仍在运行的 ETL/AI/队列活动。"""
steps: list[RuntimeTransitionStep] = []
# 1. 终止当前进程内 ETL 执行。
try:
from app.services.task_executor import task_executor
running_ids = task_executor.get_running_ids()
cancelled = 0
for execution_id in running_ids:
if await task_executor.cancel(execution_id):
cancelled += 1
steps.append(RuntimeTransitionStep(
key="cancel_etl_processes",
title="终止当前 ETL 执行",
status="success",
count=cancelled,
detail=f"检测到 {len(running_ids)} 个当前进程内执行,已发送取消信号。",
))
except Exception as exc:
logger.exception("终止 ETL 执行失败")
steps.append(RuntimeTransitionStep(
key="cancel_etl_processes",
title="终止当前 ETL 执行",
status="warning",
detail=str(exc)[:300],
))
# 2. 清理当前门店队列中未完成任务。
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE task_queue
SET status = 'cancelled',
finished_at = NOW(),
error_message = COALESCE(error_message, '') || E'\n[runtime-context] 切换业务运行上下文时取消'
WHERE site_id = %s
AND status IN ('pending', 'running')
""",
(site_id,),
)
queue_cancelled = cur.rowcount
conn.commit()
steps.append(RuntimeTransitionStep(
key="cancel_task_queue",
title="取消 ETL 队列",
status="success",
count=queue_cancelled,
detail="已取消当前门店 pending/running 的 task_queue 记录。",
))
except Exception as exc:
conn.rollback()
logger.exception("取消 ETL 队列失败")
steps.append(RuntimeTransitionStep(
key="cancel_task_queue",
title="取消 ETL 队列",
status="warning",
detail=str(exc)[:300],
))
finally:
conn.close()
# 3. 取消当前站点内存 AI 调用链,并标记未完成 ai_trigger_jobs。
try:
from app.ai.dispatcher import get_dispatcher
dispatcher = get_dispatcher()
cancelled = dispatcher.cancel_running(site_id)
steps.append(RuntimeTransitionStep(
key="cancel_ai_runtime",
title="取消当前 AI 调用链",
status="success",
count=cancelled,
detail="已取消当前进程内属于该门店的 AI 异步调用链。",
))
except Exception as exc:
steps.append(RuntimeTransitionStep(
key="cancel_ai_runtime",
title="取消当前 AI 调用链",
status="warning",
detail=f"AI Dispatcher 不可用或取消失败:{str(exc)[:240]}",
))
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE biz.ai_trigger_jobs
SET status = 'cancelled',
finished_at = NOW(),
error_message = COALESCE(error_message, '') || E'\n[runtime-context] 切换业务运行上下文时取消'
WHERE site_id = %s
AND status IN ('pending', 'running')
""",
(site_id,),
)
ai_cancelled = cur.rowcount
conn.commit()
steps.append(RuntimeTransitionStep(
key="cancel_ai_jobs",
title="标记未完成 AI 触发",
status="success",
count=ai_cancelled,
detail="已将当前门店 pending/running 的 ai_trigger_jobs 标记为 cancelled。",
))
except Exception as exc:
conn.rollback()
logger.exception("标记 AI 触发失败")
steps.append(RuntimeTransitionStep(
key="cancel_ai_jobs",
title="标记未完成 AI 触发",
status="warning",
detail=str(exc)[:300],
))
finally:
conn.close()
return steps

View File

@@ -310,6 +310,24 @@ async def reassign_task(
)
conn.commit()
# 触发 AI 任务分配链App4 → App5
try:
from app.services.trigger_scheduler import fire_event
fire_event(
"ai_task_assigned",
{
"site_id": task["site_id"],
"member_id": task["member_id"],
"assistant_id": body.to_assistant_id,
},
)
except Exception:
logger.exception(
"触发 ai_task_assigned 事件失败: task_id=%s new_task_id=%s",
task_id, new_task_id,
)
return ReassignResponse(success=True, new_task_id=new_task_id)
except HTTPException:
conn.rollback()

View File

@@ -85,6 +85,33 @@ async def etl_completed_endpoint(
logger.exception("ETL 编排 Step2 task_generator 失败")
errors.append("task_generator failed")
# Step 3: 触发 AI 财务洞察预生成App2 × 8 时间维度)
# 若请求未带 site_id查询所有 active site 逐个触发
try:
from app.services.trigger_scheduler import fire_event
site_ids: list[int] = []
if body.site_id is not None:
site_ids = [body.site_id]
else:
from app.database import get_connection as _gc
_c = _gc()
try:
with _c.cursor() as _cur:
_cur.execute("SELECT DISTINCT site_id FROM biz.trigger_jobs WHERE site_id IS NOT NULL")
site_ids = [r[0] for r in _cur.fetchall()]
_c.commit()
finally:
_c.close()
for sid in site_ids:
try:
fire_event("ai_dws_completed", {"site_id": sid})
except Exception:
logger.exception("触发 ai_dws_completed 失败: site_id=%s", sid)
except Exception:
logger.exception("ai_dws_completed 事件批量触发失败")
success = len(errors) == 0
return EtlCompletedResponse(
success=success,

View File

@@ -204,13 +204,17 @@ async def list_site_staff(
# assumptions: cfg_assistant_level_price 有 level_code→level_name 映射
# verify: 弹窗人员下拉显示如 "初级 - 张三 - 手机号 - 入职日期 YYYY-MM-DD"
# 先查等级映射配置表feiqiu-data-rules 规则 6: 禁止硬编码)
# 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 DISTINCT level_code, level_name
FROM dws.cfg_assistant_level_price
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
"""
WHERE effective_from <= %s::date
AND effective_to >= %s::date
""",
(_ref_date, _ref_date),
)
level_map = {row[0]: row[1] for row in cur.fetchall()}

View File

@@ -244,27 +244,56 @@ async def chat_stream(
)
# 流式调用 DashScope Application API
async for chunk in client.call_app_stream(
# 返回 (text_chunk, session_id_or_none) 元组:累积最后一次 session_id 用于回写
latest_session_id: str | None = session_id
async for chunk_text, chunk_session_id in client.call_app_stream(
app_id=config.app_id_1_chat,
prompt=prompt,
session_id=session_id,
biz_params=biz_params,
):
full_reply_parts.append(chunk)
if chunk_session_id:
latest_session_id = chunk_session_id
if not chunk_text:
continue
full_reply_parts.append(chunk_text)
tokens_total += 1
# SSE trace: 每 10 个 token 记录一次
record_sse_token(token_count=1, total_tokens=tokens_total)
yield f"event: message\ndata: {json.dumps({'token': chunk}, ensure_ascii=False)}\n\n"
yield f"event: message\ndata: {json.dumps({'token': chunk_text}, ensure_ascii=False)}\n\n"
# 流结束:拼接完整回复并持久化
full_reply = "".join(full_reply_parts)
estimated_tokens = len(full_reply)
# Phase 1.3assistant 消息挂 reference_card若用户从特定详情页入口发起对话
try:
from app.ai.references import build_app1_reference_card
_ref_card = None
_pc = body.page_context or {}
_ctx_id = _pc.get("contextId") or _pc.get("taskId") or _pc.get("customerId") or _pc.get("coachId")
if body.source_page and _ctx_id:
_ref_card = build_app1_reference_card(body.source_page, _ctx_id)
except Exception:
logger.warning("构建 reference_card 失败", exc_info=True)
_ref_card = None
ai_msg_id, ai_created_at = svc._save_message(
body.chat_id, "assistant", full_reply, tokens_used=estimated_tokens,
body.chat_id, "assistant", full_reply,
tokens_used=estimated_tokens,
reference_card=_ref_card,
)
svc._update_session_metadata(body.chat_id, full_reply)
# multi-turn 启用:回写百炼返回的 session_id若首次对话或服务端更新
if latest_session_id and latest_session_id != session_id:
try:
svc.save_session_id(body.chat_id, latest_session_id)
except Exception:
logger.warning(
"save_session_id 失败 chat_id=%s", body.chat_id, exc_info=True,
)
# 发送 done 事件
done_data = json.dumps(
{"messageId": ai_msg_id, "createdAt": ai_created_at},

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""小程序业务时钟路由。
仅用于小程序读取当前门店的"业务日 / 业务年月 / 模式"——sandbox 模式下,
小程序的 performance / task-list / customer-records 等页面应以 RuntimeContext
返回的业务时钟为准,禁止再用 ``new Date()`` 构造请求参数。
端点:
- GET /api/xcx/runtime/clock — 返回当前门店的业务时钟与运行模式live / sandbox
所有端点均需 JWTapproved 状态),但不要求特定模块权限。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.auth.dependencies import CurrentUser
from app.middleware.permission import require_approved
from app.services.runtime_context import get_runtime_context
from app.trace.decorators import trace_service
router = APIRouter(prefix="/api/xcx/runtime", tags=["小程序业务时钟"])
@router.get("/clock")
@trace_service("获取业务时钟", "Get business clock")
async def get_business_clock(
user: CurrentUser = Depends(require_approved),
):
"""返回当前门店的业务时钟。
返回示例live::
{
"mode": "live",
"business_date": "2026-05-02",
"business_year": 2026,
"business_month": 5,
"business_year_month": "2026-05",
"is_sandbox": false,
"sandbox_date": null
}
sandbox 模式下 ``business_date`` 等于配置的 ``sandbox_date``。
小程序页面应使用本接口结果替代 ``new Date()``,以确保 sandbox 模式下
展示和请求都对齐到 sandbox_date。
"""
ctx = get_runtime_context(user.site_id)
bd = ctx.business_date
return {
"mode": ctx.mode,
"business_date": bd.isoformat(),
"business_year": bd.year,
"business_month": bd.month,
"business_year_month": f"{bd.year:04d}-{bd.month:02d}",
"business_now": ctx.business_now.isoformat(),
"is_sandbox": ctx.is_sandbox,
"sandbox_date": ctx.sandbox_date.isoformat() if ctx.sandbox_date else None,
"sandbox_instance_id": ctx.sandbox_instance_id,
}

View File

@@ -196,6 +196,37 @@ class BatchRunConfirmResponse(BaseModel):
status: str # "started"
# ── 按需单 App 执行(/run/{app_type})──────────────────────
class RunAppRequest(BaseModel):
"""按需执行单个 App 请求体。
context 字段根据 app_type 不同有不同约束:
- app2_finance: site_id + time_dimension + areaarea 默认 all
- app3_clue / app7_customer: site_id + member_id
- app4_analysis / app5_tactics: site_id + member_id + assistant_id
- app6_note: site_id + member_id + note_content + noted_by_name
- app8_consolidation: site_id + member_id
"""
site_id: int
member_id: int | None = None
assistant_id: int | None = None
time_dimension: str | None = None
area: str | None = None # App2 专用,默认 all
note_content: str | None = None
noted_by_name: str | None = None
noted_by_created_at: str | None = None
class RunAppResponse(BaseModel):
"""按需执行单个 App 响应。"""
app_type: str
success: bool
result: dict | None = None # 百炼返回的 JSON成功时
error: str | None = None # 错误描述(失败时)
# ── 告警 ──────────────────────────────────────────────────
@@ -211,3 +242,64 @@ class AlertActionResponse(BaseModel):
"""告警操作(确认/忽略)响应。"""
id: int
alert_status: str
# ── 触发器管理biz.trigger_jobs─────────────────────────
class TriggerItem(BaseModel):
"""触发器单条记录。"""
id: int
job_name: str
job_type: str
trigger_condition: str # event / cron / interval
trigger_config: dict # {"event_name": ...} 或 {"cron_expression": ...}
status: str # enabled / disabled
description: str | None = None
last_run_at: str | None = None
next_run_at: str | None = None
last_error: str | None = None
class TriggerUpdateRequest(BaseModel):
"""触发器更新请求3 个字段至少填一个)。"""
status: str | None = None # enabled / disabled
cron_expression: str | None = None # 标准 5 段 cron
description: str | None = None
# ── 预热进度app2_finance 72 组合)───────────────────────
class PrewarmMissingItem(BaseModel):
"""缺失的预热组合项。"""
target_id: str # this_month__all
time_dimension: str
area: str
class PrewarmProgressResponse(BaseModel):
"""app2_finance 预热进度响应。"""
total: int # 固定 72
done: int
missing: list[PrewarmMissingItem]
last_updated: str | None = None
# ── 手动事件触发(越过去重)───────────────────────────────
class ManualTriggerRequest(BaseModel):
"""手动触发 AI 事件请求。"""
event_type: str # consumption / dws_completed / note_created / task_assigned
site_id: int
member_id: int | None = None
assistant_id: int | None = None
payload: dict | None = None
is_forced: bool = True # 默认跳过去重
class ManualTriggerResponse(BaseModel):
"""手动事件触发响应。"""
trigger_job_id: int
status: str = "pending"

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""业务运行上下文 API Schema。"""
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field
RuntimeMode = Literal["live", "sandbox"]
AIMode = Literal["live"]
class RuntimeContextResponse(BaseModel):
site_id: int
mode: RuntimeMode
business_day_start_hour: int
business_date: date
business_now: datetime
sandbox_date: date | None = None
sandbox_instance_id: str | None = None
ai_mode: AIMode = "live"
status: str = "active"
is_sandbox: bool = False
class RuntimeTransitionStep(BaseModel):
key: str
title: str
status: Literal["success", "skipped", "warning", "failed"]
detail: str = ""
count: int = 0
class RuntimeSwitchRequest(BaseModel):
site_id: int = Field(..., ge=1)
mode: RuntimeMode
sandbox_date: date | None = None
reset_sandbox: bool = True
reason: str | None = Field(default=None, max_length=500)
class RuntimeSwitchResponse(BaseModel):
context: RuntimeContextResponse
steps: list[RuntimeTransitionStep]

View File

@@ -35,9 +35,21 @@ class AdminAIService:
# ── Dashboard ─────────────────────────────────────────
async def get_dashboard(self, site_id: int | None = None) -> dict:
"""聚合所有 Dashboard 数据。"""
today_stats = await self._get_today_stats(site_id)
async def get_dashboard(
self,
site_id: int | None = None,
range_days: int | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> dict:
"""聚合所有 Dashboard 数据。
时间范围优先级:
1. 若 date_from / date_to 同时给出(指定日期)→ 闭区间 [from, to]
2. 若 range_days=N → [CURRENT_DATE - (N-1) days, 现在]
3. 默认 range_days=1今日
"""
today_stats = await self._get_range_stats(site_id, range_days, date_from, date_to)
trend_7d = await self._get_7d_trend(site_id)
app_dist = await self._get_app_distribution(site_id)
app_health = await self._get_app_health(site_id)
@@ -52,9 +64,32 @@ class AdminAIService:
"app_health": app_health,
}
async def _get_today_stats(self, site_id: int | None) -> dict:
"""今日调用次数、成功率、Token 消耗、平均延迟。"""
site_clause, params = _site_filter(site_id)
async def _get_range_stats(
self,
site_id: int | None,
range_days: int | None,
date_from: str | None,
date_to: str | None,
) -> dict:
"""指定时间段内的调用次数、成功率、Token 消耗、平均延迟。
字段名沿用 today_* 前缀以兼容前端 DashboardResponse schema。
"""
site_clause, site_params = _site_filter(site_id)
if date_from and date_to:
time_clause = "created_at >= %s::date AND created_at < (%s::date + INTERVAL '1 day')"
time_params: tuple = (date_from, date_to)
else:
days = range_days if range_days and range_days > 0 else 1
time_clause = (
"created_at >= CURRENT_DATE - (%s::int - 1) * INTERVAL '1 day' "
"AND created_at < CURRENT_DATE + INTERVAL '1 day'"
)
time_params = (days,)
params = time_params + site_params
conn = get_connection()
try:
with conn.cursor() as cur:
@@ -67,8 +102,7 @@ class AdminAIService:
COALESCE(AVG(latency_ms) FILTER (WHERE latency_ms IS NOT NULL), 0)
AS avg_latency
FROM biz.ai_run_logs
WHERE created_at >= CURRENT_DATE
AND created_at < CURRENT_DATE + INTERVAL '1 day'
WHERE {time_clause}
{site_clause}
""",
params,
@@ -466,6 +500,22 @@ class AdminAIService:
finally:
conn.close()
# Phase 1.4:广播 cache_invalidated 事件admin-web / 小程序可实时刷新
if affected > 0:
try:
from app.ai.event_bus import AIEvent, get_event_bus
get_event_bus().publish(AIEvent(
type="cache_invalidated",
site_id=site_id,
payload={
"cache_type": app_type,
"member_id": member_id,
"affected": affected,
},
))
except Exception:
logger.debug("cache_invalidated 事件广播失败", exc_info=True)
return affected
# ── Token 预算 ────────────────────────────────────────
@@ -699,6 +749,140 @@ class AdminAIService:
return "ignored"
# ── 触发器管理biz.trigger_jobs───────────────────────
async def list_triggers(self) -> list[dict]:
"""列出所有 AI 相关触发器job_type 以 ai_ 开头 + task_generator
返回字段id / job_name / job_type / trigger_condition / trigger_config /
status / description / last_run_at / next_run_at / last_error
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, job_name, job_type, trigger_condition,
trigger_config, status, description,
last_run_at, next_run_at, last_error
FROM biz.trigger_jobs
WHERE job_type LIKE 'ai_%' OR job_name = 'task_generator'
ORDER BY trigger_condition DESC, job_name
"""
)
cols = [d[0] for d in cur.description]
rows = cur.fetchall()
conn.commit()
finally:
conn.close()
return [_row_to_dict(cols, r) for r in rows]
async def update_trigger(
self, trigger_id: int,
status_new: str | None = None,
cron_expression: str | None = None,
description: str | None = None,
) -> dict:
"""更新触发器:启用/禁用、修改 cron、改描述。
仅允许修改 ai_ 前缀或 task_generator 的触发器。
"""
if status_new is not None and status_new not in ("enabled", "disabled"):
raise ValueError(f"非法 status: {status_new}")
sets: list[str] = []
params: list = []
if status_new is not None:
sets.append("status = %s")
params.append(status_new)
if cron_expression is not None:
sets.append("trigger_config = jsonb_set(trigger_config, '{cron_expression}', to_jsonb(%s::text))")
params.append(cron_expression)
if description is not None:
sets.append("description = %s")
params.append(description)
if not sets:
raise ValueError("至少修改一个字段")
params.append(trigger_id)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
f"""
UPDATE biz.trigger_jobs
SET {", ".join(sets)}
WHERE id = %s
AND (job_type LIKE 'ai_%%' OR job_name = 'task_generator')
RETURNING id, job_name, job_type, trigger_condition,
trigger_config, status, description,
last_run_at, next_run_at, last_error
""",
params,
)
row = cur.fetchone()
if row is None:
conn.rollback()
raise ValueError("触发器不存在或不可修改")
cols = [d[0] for d in cur.description]
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
return _row_to_dict(cols, row)
# ── 预热进度app2_finance 72 组合)──────────────────────
async def get_prewarm_progress(self, site_id: int) -> dict:
"""查询 app2_finance 72 组合缓存进度。
返回total=72, done=N, missing=[{time_dimension, area}], last_updated
"""
time_dims = (
"this_month", "last_month", "this_week", "last_week",
"this_quarter", "last_quarter", "last_3_months", "last_6_months",
)
areas = (
"all", "hall", "hallA", "hallB", "hallC",
"vip", "snooker", "mahjong", "ktv",
)
expected = {f"{t}__{a}" for t in time_dims for a in areas}
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT target_id, max(created_at) AS last_updated
FROM biz.ai_cache
WHERE cache_type = 'app2_finance'
AND site_id = %s
AND target_id LIKE %s ESCAPE '\\'
GROUP BY target_id
""",
(site_id, r'%\_\_%'),
)
rows = cur.fetchall()
conn.commit()
finally:
conn.close()
done_map = {r[0]: r[1] for r in rows}
missing = sorted(expected - set(done_map.keys()))
last = max(done_map.values()) if done_map else None
return {
"total": len(expected),
"done": len(expected & set(done_map.keys())),
"missing": [
{"target_id": m, "time_dimension": m.split("__")[0], "area": m.split("__")[1]}
for m in missing
],
"last_updated": last.isoformat() if last else None,
}
# ── 工具函数 ──────────────────────────────────────────────

View File

@@ -195,6 +195,7 @@ from typing import Any
from fastapi import HTTPException
from app.services import fdw_queries
from app.services.runtime_context import get_runtime_context, task_runtime_filter
logger = logging.getLogger(__name__)
@@ -260,7 +261,8 @@ async def get_coach_board(
detail="最近6个月不支持客源储值排序",
)
start_date, end_date = _calc_date_range(time)
runtime_ctx = get_runtime_context(site_id)
start_date, end_date = _calc_date_range(time, ref_date=runtime_ctx.business_date)
start_str = str(start_date)
end_str = str(end_date)
@@ -415,20 +417,22 @@ def _query_coach_tasks(
result: dict[int, dict] = {}
try:
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
# 狭义召回+回访完成数:均从 coach_tasks 统计status='completed' 表示助教亲自完成
cur.execute(
"""
f"""
SELECT assistant_id, task_type, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE assistant_id = ANY(%s)
AND site_id = %s
{runtime_clause}
AND completed_at >= %s::date
AND completed_at < (%s::date + INTERVAL '1 day')::timestamptz
AND status = 'completed'
GROUP BY assistant_id, task_type
""",
(assistant_ids, site_id, start_date, end_date),
[assistant_ids, site_id, *runtime_params, start_date, end_date],
)
for row in cur.fetchall():
aid, task_type, cnt = row[0], row[1], row[2] or 0
@@ -470,13 +474,27 @@ def _batch_ideal_days(conn: Any, site_id: int, member_ids: list[int]) -> dict[in
return result
def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, list[dict]]:
"""批量查询客户-助教服务明细loyal 维度 coachDetails 用)。每个客户前 5 个。"""
def _batch_coach_details(
conn: Any,
site_id: int,
member_ids: list[int],
*,
ref_date: date | None = None,
) -> dict[int, list[dict]]:
"""批量查询客户-助教服务明细loyal 维度 coachDetails 用)。每个客户前 5 个。
ref_date 默认从 RuntimeContext 取业务日,用于把 60 天消费窗口的上界落到 ``ref_date`` 上,
避免 sandbox 模式下读到 sandbox_date 之后的真实消费。
"""
from app.services.fdw_queries import _fdw_context
from app.services.runtime_context import as_runtime_today_param
ref = ref_date or as_runtime_today_param(site_id, conn=conn)
result: dict[int, list[dict]] = {mid: [] for mid in member_ids}
try:
with _fdw_context(conn, site_id) as cur:
# CHANGE 2026-03-29 | coach_spend 改为从 dwd_assistant_service_log 聚合 60 天消费
# CHANGE 2026-05-02 | 用 ref_date业务日替代 CURRENT_DATE沙箱不读「未来」
cur.execute(
"""
SELECT ri.member_id,
@@ -493,7 +511,8 @@ def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict
SUM(ledger_amount) AS spend_60d
FROM app.v_dwd_assistant_service_log
WHERE is_delete = 0
AND create_time >= CURRENT_DATE - INTERVAL '60 days'
AND create_time >= (%s::date - INTERVAL '60 days')
AND create_time < (%s::date + INTERVAL '1 day')
AND tenant_member_id = ANY(%s)
GROUP BY tenant_member_id, site_assistant_id
) s60 ON ri.member_id = s60.tenant_member_id
@@ -502,7 +521,7 @@ def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict
AND (da.leave_status IS NULL OR da.leave_status = 0)
ORDER BY ri.member_id, ri.rs_display DESC
""",
(member_ids, member_ids),
(ref, ref, member_ids, member_ids),
)
for row in cur.fetchall():
mid = row[0]
@@ -690,7 +709,8 @@ async def get_finance_board(
- area≠all 时 overview 覆盖逻辑保留
- compare=1 时对上期执行同样缓存/日粒度逻辑
"""
start_date, end_date = _calc_date_range(time)
runtime_ctx = get_runtime_context(site_id)
start_date, end_date = _calc_date_range(time, ref_date=runtime_ctx.business_date)
start_str = str(start_date)
end_str = str(end_date)

View File

@@ -234,23 +234,14 @@ class ChatService:
INSERT INTO biz.ai_conversations
(user_id, nickname, app_id, site_id, context_type, context_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, EXTRACT(EPOCH FROM created_at)::bigint
RETURNING id
""",
(str(user_id), nickname, APP_ID, site_id, context_type, context_id),
)
result = cur.fetchone()
new_id = result[0]
created_ts = result[1]
# 生成 session_id 并回写格式conv_{id}_{timestamp}
session_id = f"conv_{new_id}_{created_ts}"
cur.execute(
"""
UPDATE biz.ai_conversations SET session_id = %s WHERE id = %s
""",
(session_id, new_id),
)
new_id = cur.fetchone()[0]
# session_id 初始保持 NULL首次对话由百炼返回后再回写。
# 参见 P14 spec §2.3:后端不再自生 session_id交由百炼云端管理。
conn.commit()
return new_id
except Exception:
@@ -274,6 +265,34 @@ class ChatService:
finally:
conn.close()
@trace_service("保存百炼 session_id", "Save bailian session ID")
def save_session_id(self, chat_id: int, session_id: str) -> None:
"""流式回复完成后,将百炼返回的 session_id 回写 ai_conversations。
multi-turn 启用:
- 首次对话 session_id=NULL → 百炼分配新 session → 这里回写
- 下次对话 get_session_id 返回该值 → 传给百炼关联历史上下文
幂等:同一对话多次调用覆盖最新 session_id通常保持稳定
"""
if not session_id:
return
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE biz.ai_conversations SET session_id = %s WHERE id = %s",
(session_id, chat_id),
)
conn.commit()
except Exception:
conn.rollback()
logger.warning(
"保存 session_id 失败: chat_id=%s", chat_id, exc_info=True,
)
finally:
conn.close()
# ------------------------------------------------------------------
# CHAT-2: 消息列表
# ------------------------------------------------------------------
@@ -662,7 +681,10 @@ class ChatService:
"""查询客户近 30 天消费金额items_sum 口径)。
⚠️ DWD-DOC 规则 1: 使用 ledger_amountitems_sum 口径),禁用 consume_money。
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」消费。
"""
from app.services.runtime_context import as_runtime_today_param
ref = as_runtime_today_param(site_id, conn=conn)
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
@@ -670,16 +692,22 @@ class ChatService:
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
AND create_time >= (%s::date - INTERVAL '30 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
""",
(member_id,),
(member_id, ref, ref),
)
row = cur.fetchone()
return Decimal(str(row[0])) if row and row[0] is not None else None
@staticmethod
def _get_visit_count_30d(conn: Any, site_id: int, member_id: int) -> int | None:
"""查询客户近 30 天到店次数。"""
"""查询客户近 30 天到店次数。
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」到店。
"""
from app.services.runtime_context import as_runtime_today_param
ref = as_runtime_today_param(site_id, conn=conn)
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
@@ -687,9 +715,10 @@ class ChatService:
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
AND create_time >= (%s::date - INTERVAL '30 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
""",
(member_id,),
(member_id, ref, ref),
)
row = cur.fetchone()
return int(row[0]) if row and row[0] is not None else None

View File

@@ -147,7 +147,9 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
if not assistant_info:
raise HTTPException(status_code=404, detail="助教不存在")
now = datetime.date.today()
# 业务时间锚sandbox 模式下用 business_date避免读到 sandbox_date 之后真实绩效
from app.services.runtime_context import as_runtime_today_param
now = as_runtime_today_param(site_id, conn=conn)
# 门店名称(用于小程序 banner 展示,跟随被查看助教所在门店)
# 必须在所有 fdw 查询前执行:后续任意 fdw 查询失败会污染事务
@@ -713,7 +715,9 @@ def _build_history_months(
4. 本月 estimated=True历史月份 estimated=False
5. 格式化customers→"22人"hours→"87.5h"salary→"¥6,950"
"""
now = datetime.date.today()
# 业务时间锚sandbox 模式下用 business_date 计算最近 6 个月
from app.services.runtime_context import as_runtime_today_param
now = as_runtime_today_param(site_id, conn=conn)
# 生成最近 6 个月的月份列表(含本月)
months: list[str] = []

View File

@@ -501,6 +501,9 @@ def _build_coach_tasks(
logger.warning("批量查询助教信息失败", exc_info=True)
# 批量查询 60 天统计(一次 FDW 查询)
# 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, conn=conn)
stats_map: dict = {}
try:
with fdw_queries._fdw_context(conn, site_id) as cur:
@@ -513,10 +516,11 @@ def _build_coach_tasks(
WHERE tenant_member_id = %s
AND site_assistant_id = ANY(%s)
AND is_delete = 0
AND create_time >= CURRENT_DATE - INTERVAL '60 days'
AND create_time >= (%s::date - INTERVAL '60 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
GROUP BY site_assistant_id
""",
(customer_id, assistant_ids),
(customer_id, assistant_ids, ref_date, ref_date),
)
for row in cur.fetchall():
svc = int(row[1]) if row[1] else 0

View File

@@ -80,7 +80,7 @@ def _get_etl_connection(site_id: int):
@contextmanager
def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
"""
上下文管理器:直连 ETL 库 + SET LOCAL app.current_site_id。
上下文管理器:直连 ETL 库 + SET LOCAL app.current_site_id + app.current_business_date
⚠️ 不使用 zqyy_app 的 fdw_etl.* foreign table而是直连 ETL 库
查询 app.v_* RLS 视图。原因postgres_fdw 不传递自定义 GUC 参数
@@ -91,7 +91,31 @@ def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
CHANGE 2026-03-26 | ETL 连接复用:传入 etl_conn 时复用已有连接(不关闭),
不传时新建连接并在 yield 后自动关闭。避免同一请求内多次新建连接(每次 ~2.6s)。
CHANGE 2026-05-02 | 同时设置 app.current_business_date / app.current_runtime_mode
供 RLS 视图层C 方案做日期上界裁剪。conn=None 时降级 live。
"""
from app.services.runtime_context import (
MODE_LIVE,
MODE_SANDBOX,
get_runtime_context,
)
# 业务日:优先从 zqyy_app 业务库的 RuntimeContext 读取conn 不可用时降级为系统今天
bd_str = ""
rt_mode = MODE_LIVE
try:
if conn is not None:
ctx = get_runtime_context(site_id, conn=conn)
bd_str = ctx.business_date.isoformat()
rt_mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
else:
from datetime import date as _date
bd_str = _date.today().isoformat()
except Exception:
from datetime import date as _date
bd_str = _date.today().isoformat()
rt_mode = MODE_LIVE
owned = etl_conn is None
if owned:
etl_conn = _get_etl_connection(site_id)
@@ -99,6 +123,8 @@ def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
with etl_conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
cur.execute("SET LOCAL app.current_business_date = %s", (bd_str,))
cur.execute("SET LOCAL app.current_runtime_mode = %s", (rt_mode,))
yield cur
etl_conn.commit()
finally:
@@ -180,33 +206,53 @@ def get_last_visit_days(
"""
批量查询客户距上次到店天数。
来源: app.v_dws_member_consumption_summary.days_since_last基于结算单
FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录。
来源: app.v_dws_member_consumption_summary。
consumption_summary 按 stat_date 有多行快照,取最新一行。
CHANGE 2026-05-02 | 修复客户看板「最近到店」数据不准的问题:
- 旧版直接用 days_since_lastETL 在 stat_date 那天预计算的快照值)。
若 ETL 没跑、跑得迟、或 sandbox_date 与 stat_date 不一致,结果就会严重失真。
- 新版改为实时计算:``business_date - last_consume_date``
仅取 ``stat_date <= business_date`` 的快照行,沙箱模式下也能拿到一致结果。
返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。
"""
if not member_ids:
return {}
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
result: dict[int, int | None] = {}
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
cur.execute(
"""
SELECT member_id, days_since_last
SELECT DISTINCT ON (member_id)
member_id,
last_consume_date,
stat_date
FROM app.v_dws_member_consumption_summary
WHERE member_id = ANY(%s)
AND days_since_last IS NOT NULL
AND stat_date <= %s
ORDER BY member_id, stat_date DESC
""",
(member_ids,),
(member_ids, ref_date),
)
seen: set[int] = set()
for row in cur.fetchall():
mid = row[0]
if mid not in seen:
seen.add(mid)
result[mid] = row[1]
last_consume = row[1]
if last_consume is None:
result[mid] = None
continue
try:
# last_consume_date 在 DWS 中是 date少数实现可能给 timestamp统一裁剪
if hasattr(last_consume, "date"):
last_consume = last_consume.date()
days = (ref_date - last_consume).days
result[mid] = max(days, 0)
except Exception:
result[mid] = None
return result
@@ -420,22 +466,33 @@ def batch_query_for_task_list(
# 3. 最后到店天数(基于消费汇总表,口径=结算单)
# FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录
# CHANGE 2026-05-02 | 实时按 business_date - last_consume_date 计算,
# 不再依赖 ETL 预计算的 days_since_last解决看板显示偏差
from app.services.runtime_context import as_runtime_today_param
_ref_date = as_runtime_today_param(site_id, conn=conn)
cur.execute(
"""
SELECT member_id, days_since_last
SELECT DISTINCT ON (member_id)
member_id, last_consume_date, stat_date
FROM app.v_dws_member_consumption_summary
WHERE member_id = ANY(%s)
AND days_since_last IS NOT NULL
AND stat_date <= %s
ORDER BY member_id, stat_date DESC
""",
(member_ids,),
(member_ids, _ref_date),
)
seen_members: set[int] = set()
for row in cur.fetchall():
mid = row[0]
if mid not in seen_members:
seen_members.add(mid)
last_visit_map[mid] = row[1]
last_consume = row[1]
if last_consume is None:
last_visit_map[mid] = None
continue
try:
if hasattr(last_consume, "date"):
last_consume = last_consume.date()
last_visit_map[mid] = max((_ref_date - last_consume).days, 0)
except Exception:
last_visit_map[mid] = None
# 4. RS 指数
cur.execute(
@@ -486,10 +543,11 @@ def batch_query_for_task_list(
WHERE sl.site_assistant_id = %s
AND sl.tenant_member_id = ANY(%s)
AND sl.is_delete = 0
AND sl.create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
AND sl.create_time >= (%s::date - INTERVAL '60 days')::timestamptz
AND sl.create_time < (%s::date + INTERVAL '1 day')::timestamptz
GROUP BY sl.tenant_member_id
""",
(assistant_id, member_ids),
(assistant_id, member_ids, _ref_date, _ref_date),
)
for row in cur.fetchall():
recent60d_map[row[0]] = {
@@ -559,15 +617,19 @@ def batch_query_for_task_list(
# 8. 绩效档位配置(用于构建 tier_nodes + bonus_money 计算)
# CHANGE 2026-03-24 | 增加 bonus_deduction_ratio 用于打赏课抽成差额计算
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱按当时生效档位
from app.services.runtime_context import as_runtime_today_param as _rt_today
_ref_date = _rt_today(site_id, conn=conn)
cur.execute(
"""
SELECT tier_id, tier_code, tier_name, tier_level,
min_hours, max_hours, base_deduction, bonus_deduction_ratio
FROM app.v_cfg_performance_tier
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
WHERE effective_from <= %s::date
AND effective_to >= %s::date
ORDER BY tier_level
"""
""",
(_ref_date, _ref_date),
)
tier_rows = cur.fetchall()
performance_tiers = [
@@ -640,17 +702,21 @@ def get_performance_tiers(
返回 [{tier_id, tier_code, tier_name, tier_level, min_hours, max_hours,
base_deduction, bonus_deduction_ratio}, ...]。
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱按当时生效档位
"""
from app.services.runtime_context import as_runtime_today_param as _rt_today
ref_date = _rt_today(site_id, conn=conn)
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT tier_id, tier_code, tier_name, tier_level,
min_hours, max_hours, base_deduction, bonus_deduction_ratio
FROM app.v_cfg_performance_tier
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
WHERE effective_from <= %s::date
AND effective_to >= %s::date
ORDER BY tier_level
"""
""",
(ref_date, ref_date),
)
rows = cur.fetchall()
@@ -680,15 +746,18 @@ def get_level_map(conn: Any, site_id: int) -> dict[int, str]:
查询失败时返回空 dict调用方应优雅降级
"""
try:
from app.services.runtime_context import as_runtime_today_param as _rt_today
ref_date = _rt_today(site_id, conn=conn)
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT DISTINCT level_code, level_name
FROM app.v_cfg_assistant_level_price
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
WHERE effective_from <= %s::date
AND effective_to >= %s::date
ORDER BY level_code
"""
""",
(ref_date, ref_date),
)
return {row[0]: row[1] for row in cur.fetchall()}
except Exception:
@@ -1198,8 +1267,11 @@ def get_coach_60d_stats(
来源: app.v_dwd_assistant_service_log。
⚠️ 废单排除: is_delete = 0。
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」60 天。
返回 {service_count, total_hours, avg_hours}。
"""
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
@@ -1212,9 +1284,10 @@ def get_coach_60d_stats(
WHERE site_assistant_id = %s
AND tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
AND create_time >= (%s::date - INTERVAL '60 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
""",
(assistant_id, member_id),
(assistant_id, member_id, ref_date, ref_date),
)
row = cur.fetchone()
if not row:
@@ -1917,14 +1990,17 @@ def get_customer_board_recall(
total = cur.fetchone()[0]
# 分页数据
# CHANGE 2026-05-02 | elapsed_days/overdue_days 用 business_date 替代 CURRENT_DATE
from app.services.runtime_context import as_runtime_today_param as _rt_today
ref_date = _rt_today(site_id, conn=conn)
offset = (page - 1) * page_size
cur.execute(
f"""
SELECT wi.member_id,
dm.nickname,
wi.ideal_interval_days,
CURRENT_DATE - wi.last_visit_time::date AS elapsed_days,
(CURRENT_DATE - wi.last_visit_time::date) - COALESCE(wi.ideal_interval_days, 0) AS overdue_days,
%s::date - wi.last_visit_time::date AS elapsed_days,
(%s::date - wi.last_visit_time::date) - COALESCE(wi.ideal_interval_days, 0) AS overdue_days,
wi.visits_30d,
wi.display_score,
COALESCE(ca.balance, 0) AS balance
@@ -1937,11 +2013,11 @@ def get_customer_board_recall(
WHERE scd2_is_current = 1
GROUP BY tenant_member_id
) ca ON wi.member_id = ca.tenant_member_id
WHERE 1=1 {proj_clause}
WHERE wi.last_visit_time <= %s::date + INTERVAL '1 day' {proj_clause}
ORDER BY wi.display_score DESC, wi.member_id
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
(ref_date, ref_date, ref_date, *proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
@@ -2165,6 +2241,10 @@ def get_customer_board_recharge(
)
total = cur.fetchone()[0]
# CHANGE 2026-05-02 | 60 天充值窗口、stat_date、pay_time 全部按 business_date 截断
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
offset = (page - 1) * page_size
cur.execute(
f"""
@@ -2173,7 +2253,8 @@ def get_customer_board_recharge(
MAX(ro.pay_time::date) AS last_recharge_date,
SUM(ro.pay_amount) AS recharge_amount,
COUNT(*) FILTER (
WHERE ro.pay_time >= CURRENT_DATE - INTERVAL '60 days'
WHERE ro.pay_time >= %s::date - INTERVAL '60 days'
AND ro.pay_time < %s::date + INTERVAL '1 day'
) AS recharges_60d,
COALESCE(ca_agg.balance, 0) AS current_balance,
cs.days_since_last
@@ -2190,15 +2271,16 @@ def get_customer_board_recharge(
SELECT cs2.days_since_last
FROM app.v_dws_member_consumption_summary cs2
WHERE cs2.member_id = ro.member_id
AND cs2.stat_date <= %s
ORDER BY cs2.stat_date DESC
LIMIT 1
) cs ON true
WHERE 1=1 {proj_clause}
WHERE ro.pay_time <= %s::date + INTERVAL '1 day' {proj_clause}
GROUP BY ro.member_id, dm.nickname, ca_agg.balance, cs.days_since_last
ORDER BY MAX(ro.pay_time::date) DESC, ro.member_id
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
(ref_date, ref_date, ref_date, ref_date, *proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
@@ -2228,6 +2310,13 @@ def get_customer_board_recent(
不再硬编码为 0。来源: v_dws_member_visit_detail + v_dim_member + v_dws_member_winback_index。
按 last_visit_date 降序。
"""
# CHANGE 2026-05-02 | 客户看板「最近到店」修复:
# 1) WHERE/COUNT 中的 30/60 天窗口按 business_date 计算,沙箱不读「未来」到店;
# 2) days_ago 用 business_date - last_visit_date与窗口对齐。
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
proj_clause, proj_params = _project_filter_clause(project, "vd.member_id")
with _fdw_context(conn, site_id) as cur:
@@ -2235,9 +2324,9 @@ def get_customer_board_recent(
f"""
SELECT COUNT(DISTINCT vd.member_id)
FROM app.v_dws_member_visit_detail vd
WHERE 1=1 {proj_clause}
WHERE vd.visit_date <= %s {proj_clause}
""",
proj_params,
(ref_date, *proj_params),
)
total = cur.fetchone()[0]
@@ -2248,11 +2337,11 @@ def get_customer_board_recent(
SELECT vd.member_id,
MAX(vd.visit_date) AS last_visit_date,
COUNT(*) AS total_visits,
COUNT(*) FILTER (WHERE vd.visit_date >= CURRENT_DATE - INTERVAL '30 days') AS visits_30d,
COUNT(*) FILTER (WHERE vd.visit_date >= CURRENT_DATE - INTERVAL '60 days') AS visits_60d,
COUNT(*) FILTER (WHERE vd.visit_date >= %s::date - INTERVAL '30 days') AS visits_30d,
COUNT(*) FILTER (WHERE vd.visit_date >= %s::date - INTERVAL '60 days') AS visits_60d,
AVG(vd.total_consume) AS avg_spend
FROM app.v_dws_member_visit_detail vd
WHERE 1=1 {proj_clause}
WHERE vd.visit_date <= %s {proj_clause}
GROUP BY vd.member_id
)
SELECT ma.member_id,
@@ -2271,14 +2360,13 @@ def get_customer_board_recent(
ORDER BY ma.last_visit_date DESC, ma.member_id
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
(ref_date, ref_date, ref_date, *proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
last_visit = row[2]
# CHANGE 2026-03-29 | 补充 days_ago距今天数和 visits_60d
from datetime import date as _date
days_ago = (_date.today() - last_visit).days if last_visit else None
# CHANGE 2026-05-02 | days_ago 按 business_date 计算,沙箱与窗口对齐
days_ago = (ref_date - last_visit).days if last_visit else None
items.append({
"member_id": row[0],
"name": row[1] or "",
@@ -2378,6 +2466,10 @@ def get_customer_board_freq60(
按 visit_count_60d 降序。
CHANGE 2026-04-08 | Fix同 spend60DISTINCT ON 取最新快照。
"""
# CHANGE 2026-05-02 | freq60 全链路按 business_date 截断stat_date <= ref_date + 8 周窗口)
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
proj_clause, proj_params = _project_filter_clause(project, "cs.member_id")
with _fdw_context(conn, site_id) as cur:
@@ -2387,11 +2479,11 @@ def get_customer_board_freq60(
FROM (
SELECT DISTINCT ON (cs.member_id) cs.member_id
FROM app.v_dws_member_consumption_summary cs
WHERE 1=1 {proj_clause}
WHERE cs.stat_date <= %s {proj_clause}
ORDER BY cs.member_id, cs.stat_date DESC
) sub
""",
proj_params,
(ref_date, *proj_params),
)
total = cur.fetchone()[0]
@@ -2402,7 +2494,7 @@ def get_customer_board_freq60(
SELECT DISTINCT ON (cs.member_id)
cs.member_id, cs.visit_count_60d, cs.consume_amount_60d
FROM app.v_dws_member_consumption_summary cs
WHERE 1=1 {proj_clause}
WHERE cs.stat_date <= %s {proj_clause}
ORDER BY cs.member_id, cs.stat_date DESC
)
SELECT cs.member_id,
@@ -2415,7 +2507,7 @@ def get_customer_board_freq60(
ORDER BY cs.visit_count_60d DESC, cs.member_id
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
(ref_date, *proj_params, page_size, offset),
)
items = []
member_ids = []
@@ -2436,21 +2528,31 @@ def get_customer_board_freq60(
# 批量查询 8 周到店数据
if member_ids:
weekly_map = _get_weekly_visits_batch(cur, member_ids)
weekly_map = _get_weekly_visits_batch(cur, member_ids, ref_date=ref_date)
for item in items:
item["weekly_visits"] = weekly_map.get(item["member_id"], _empty_weekly())
return {"items": items, "total": total, "page": page, "page_size": page_size}
def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[dict]]:
def _get_weekly_visits_batch(
cur: Any, member_ids: list[int], *, ref_date: Any = None,
) -> dict[int, list[dict]]:
"""
批量查询客户最近 8 周的到店次数(用于 freq60 维度柱状图)。
CHANGE 2026-04-07 | Fix-5数据源从 v_dwd_assistant_service_log 改为
v_dwd_settlement_headsettle_type IN (1,3)),与汇总维度口径一致。
CHANGE 2026-05-02 | 8 周窗口锚定 ref_date业务日沙箱不读「未来」。
返回 {member_id: [{val: int, pct: int}, ...]},固定 8 个元素。
"""
from datetime import date as _date, timedelta as _timedelta
if ref_date is None:
ref_date = _date.today()
elif hasattr(ref_date, "date") and not isinstance(ref_date, _date):
ref_date = ref_date.date()
cur.execute(
"""
WITH weekly AS (
@@ -2460,14 +2562,15 @@ def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[
FROM app.v_dwd_settlement_head
WHERE member_id = ANY(%s)
AND settle_type IN (1, 3)
AND pay_time >= CURRENT_DATE - INTERVAL '56 days'
AND pay_time >= %s::date - INTERVAL '56 days'
AND pay_time < %s::date + INTERVAL '1 day'
GROUP BY member_id, DATE_TRUNC('week', pay_time::date)
)
SELECT member_id, week_start, cnt
FROM weekly
ORDER BY member_id, week_start
""",
(member_ids,),
(member_ids, ref_date, ref_date),
)
from collections import defaultdict
@@ -2477,11 +2580,9 @@ def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[
week_key = row[1].date() if hasattr(row[1], 'date') else row[1]
raw[row[0]][str(week_key)] = row[2]
# 生成最近 8 周的周一日期
from datetime import date, timedelta
today = date.today()
this_monday = today - timedelta(days=today.weekday())
weeks = [this_monday - timedelta(weeks=i) for i in range(7, -1, -1)]
# 生成最近 8 周的周一日期,以业务日为锚
this_monday = ref_date - _timedelta(days=ref_date.weekday())
weeks = [this_monday - _timedelta(weeks=i) for i in range(7, -1, -1)]
result: dict[int, list[dict]] = {}
for mid in member_ids:

View File

@@ -259,6 +259,28 @@ async def create_note(
import asyncio
asyncio.create_task(_async_ai_score(note["id"], site_id, target_id, content))
# 触发 AI 备注分析链App6 → App8
# target_type='member' 时 target_id 即 member_id'assistant' 时不触发AI 只分析会员备注)
if target_type == "member":
try:
from app.services.trigger_scheduler import fire_event
fire_event(
"ai_note_created",
{
"site_id": site_id,
"member_id": target_id,
"note_content": content,
"noted_by_name": note.get("recorded_by_name")
or note.get("user_nickname") or "",
"noted_by_created_at": note.get("created_at") or "",
},
)
except Exception:
logger.exception(
"触发 ai_note_created 事件失败: note_id=%s member_id=%s",
note["id"], target_id,
)
return note
except HTTPException:

View File

@@ -22,6 +22,13 @@ import json
import logging
from datetime import timedelta
from app.services.runtime_context import (
LIVE_INSTANCE_ID,
MODE_LIVE,
MODE_SANDBOX,
get_runtime_context,
task_runtime_filter,
)
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
@@ -141,6 +148,10 @@ def _process_site(conn, site_id: int) -> dict:
resolved = 0
from app.services.fdw_queries import _fdw_context
runtime_ctx = get_runtime_context(site_id, conn=conn)
runtime_now = runtime_ctx.business_now
runtime_mode = MODE_SANDBOX if runtime_ctx.is_sandbox else MODE_LIVE
sandbox_instance_id = runtime_ctx.sandbox_instance_id if runtime_ctx.is_sandbox else LIVE_INSTANCE_ID
# ── 1. 获取本门店所有 MAIN 关系对 ──
with _fdw_context(conn, site_id) as cur:
@@ -173,13 +184,14 @@ def _process_site(conn, site_id: int) -> dict:
ON sl.order_settle_id = sh.order_settle_id
AND sl.is_delete = 0
WHERE sh.member_id = ANY(%s)
AND sh.pay_time <= %s
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND sl.order_assistant_type = 2)
)
GROUP BY sl.site_assistant_id, sh.member_id
""",
(member_ids,),
(member_ids, runtime_now),
)
for row in cur.fetchall():
settlement_map[(row[0], row[1])] = row[2]
@@ -190,6 +202,7 @@ def _process_site(conn, site_id: int) -> dict:
SELECT sh.member_id, MAX(sh.pay_time) AS latest_pay_time
FROM app.v_dwd_settlement_head sh
WHERE sh.member_id = ANY(%s)
AND sh.pay_time <= %s
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND EXISTS (
@@ -201,7 +214,7 @@ def _process_site(conn, site_id: int) -> dict:
)
GROUP BY sh.member_id
""",
(member_ids,),
(member_ids, runtime_now),
)
member_visited_map = {}
for row in cur.fetchall():
@@ -209,16 +222,18 @@ def _process_site(conn, site_id: int) -> dict:
# ── 3. 获取本门店所有 active 的召回/回访任务(用于匹配) ──
active_tasks_map: dict[tuple[int, int], list] = {} # (assistant_id, member_id) → [(id, task_type, created_at)]
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, assistant_id, member_id, task_type, created_at
FROM biz.coach_tasks
WHERE site_id = %s
{runtime_clause}
AND status = 'active'
AND task_type IN ('high_priority_recall', 'priority_recall', 'follow_up_visit')
""",
(site_id,),
[site_id, *runtime_params],
)
for row in cur.fetchall():
key = (row[1], row[2])
@@ -238,7 +253,7 @@ def _process_site(conn, site_id: int) -> dict:
try:
result = _process_pair(
conn, site_id, assistant_id, member_id,
latest_pay, active_tasks,
latest_pay, active_tasks, runtime_now, runtime_mode, sandbox_instance_id,
)
completed += result["completed"]
events += result["events"]
@@ -257,25 +272,26 @@ def _process_site(conn, site_id: int) -> dict:
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
f"""
SELECT id, assistant_id, task_type, created_at
FROM biz.coach_tasks
WHERE site_id = %s AND member_id = %s
{runtime_clause}
AND status = 'active'
AND task_type IN ('high_priority_recall', 'priority_recall')
AND created_at < %s
""",
(site_id, member_id, pay_time),
[site_id, member_id, *runtime_params, pay_time],
)
remaining = cur.fetchall()
for task_id, aid, task_type, _ in remaining:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'resolved', updated_at = NOW()
SET status = 'resolved', updated_at = %s
WHERE id = %s AND status = 'active'
""",
(task_id,),
(runtime_now, task_id),
)
_insert_history(
cur, task_id,
@@ -308,6 +324,9 @@ def _process_pair(
member_id: int,
latest_pay_time,
active_tasks: list[dict],
runtime_now,
runtime_mode: str,
sandbox_instance_id: str,
) -> dict:
"""
处理单个 MAIN 关系对的召回检测。
@@ -339,14 +358,16 @@ def _process_pair(
cur.execute(
"""
INSERT INTO biz.recall_events
(site_id, assistant_id, member_id, pay_time, task_id, task_type)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, (date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
(site_id, assistant_id, member_id, pay_time, task_id, task_type,
created_at, runtime_mode, sandbox_instance_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, runtime_mode, sandbox_instance_id,
(date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
DO NOTHING
RETURNING id
""",
(site_id, assistant_id, member_id, latest_pay_time,
event_task_id, event_task_type),
event_task_id, event_task_type, runtime_now, runtime_mode, sandbox_instance_id),
)
inserted = cur.fetchone()
if inserted is None:
@@ -367,10 +388,10 @@ def _process_pair(
completed_at = %s,
completed_task_type = %s,
completion_type = 'auto',
updated_at = NOW()
updated_at = %s
WHERE id = %s AND status = 'active'
""",
(latest_pay_time, task["task_type"], task["id"]),
(latest_pay_time, task["task_type"], runtime_now, task["id"]),
)
_insert_history(
cur,
@@ -393,18 +414,19 @@ def _process_pair(
SELECT id FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
AND task_type = 'follow_up_visit' AND status = 'active'
AND runtime_mode = %s AND sandbox_instance_id = %s
""",
(site_id, assistant_id, member_id),
(site_id, assistant_id, member_id, runtime_mode, sandbox_instance_id),
)
old_follow_ups = cur.fetchall()
for (old_id,) in old_follow_ups:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
SET status = 'inactive', updated_at = %s
WHERE id = %s
""",
(old_id,),
(runtime_now, old_id),
)
_insert_history(
cur, old_id,
@@ -423,11 +445,14 @@ def _process_pair(
"""
INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
expires_at, created_at, updated_at)
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, NOW(), NOW())
expires_at, created_at, updated_at, runtime_mode, sandbox_instance_id)
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, %s, %s, %s, %s)
RETURNING id
""",
(site_id, assistant_id, member_id, expires_at),
(
site_id, assistant_id, member_id, expires_at, runtime_now,
runtime_now, runtime_mode, sandbox_instance_id,
),
)
new_follow_up_id = cur.fetchone()[0]
_insert_history(

View File

@@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
"""业务运行上下文与业务时钟服务。
该模块是开发/测试沙箱的统一控制层:
- live 模式:沿用真实系统日期和正式数据。
- sandbox 模式:业务上假设今天是配置的历史日期,并用 sandbox_instance_id 隔离写入。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from app import config
_LOCAL_TZ = timezone(timedelta(hours=8))
MODE_LIVE = "live"
MODE_SANDBOX = "sandbox"
AI_MODE_LIVE = "live"
LIVE_INSTANCE_ID = "live"
@dataclass(frozen=True)
class RuntimeContext:
"""单门店当前业务运行上下文。"""
site_id: int
mode: str = MODE_LIVE
business_day_start_hour: int = config.BUSINESS_DAY_START_HOUR
sandbox_date: date | None = None
sandbox_instance_id: str | None = None
ai_mode: str = AI_MODE_LIVE
status: str = "active"
@property
def is_sandbox(self) -> bool:
return self.mode == MODE_SANDBOX and self.sandbox_date is not None
@property
def business_date(self) -> date:
if self.is_sandbox and self.sandbox_date is not None:
return self.sandbox_date
now = datetime.now(_LOCAL_TZ)
today = now.date()
if now.hour < self.business_day_start_hour:
return today - timedelta(days=1)
return today
@property
def business_now(self) -> datetime:
if not self.is_sandbox:
return datetime.now(_LOCAL_TZ)
now = datetime.now(_LOCAL_TZ)
return datetime.combine(self.business_date, now.timetz(), tzinfo=_LOCAL_TZ)
@property
def active_sandbox_instance_id(self) -> str | None:
if not self.is_sandbox:
return None
return self.sandbox_instance_id
def to_dict(self) -> dict[str, Any]:
return {
"site_id": self.site_id,
"mode": self.mode,
"business_day_start_hour": self.business_day_start_hour,
"business_date": self.business_date.isoformat(),
"business_now": self.business_now.isoformat(),
"sandbox_date": self.sandbox_date.isoformat() if self.sandbox_date else None,
"sandbox_instance_id": self.sandbox_instance_id,
"ai_mode": self.ai_mode,
"status": self.status,
"is_sandbox": self.is_sandbox,
}
def new_sandbox_instance_id() -> str:
"""生成新的沙箱实例 ID。"""
return f"sbx_{uuid.uuid4().hex[:24]}"
def _default_context(site_id: int) -> RuntimeContext:
return RuntimeContext(site_id=site_id)
def get_runtime_context(site_id: int, conn: Any | None = None) -> RuntimeContext:
"""读取门店运行上下文。
表不存在或未配置时降级为 live保证迁移前不影响正式链路。
"""
own_conn = conn is None
if own_conn:
from app.database import get_connection
conn = get_connection()
try:
with conn.cursor() as cur:
try:
cur.execute(
"""
SELECT mode, sandbox_date, sandbox_instance_id, ai_mode, status
FROM biz.site_runtime_context
WHERE site_id = %s
""",
(site_id,),
)
except Exception:
if own_conn:
conn.rollback()
return _default_context(site_id)
row = cur.fetchone()
if own_conn:
conn.commit()
finally:
if own_conn:
conn.close()
if not row:
return _default_context(site_id)
mode, sandbox_date, sandbox_instance_id, ai_mode, status = row
if mode not in (MODE_LIVE, MODE_SANDBOX):
mode = MODE_LIVE
if mode == MODE_SANDBOX and (sandbox_date is None or not sandbox_instance_id):
mode = MODE_LIVE
return RuntimeContext(
site_id=site_id,
mode=mode,
sandbox_date=sandbox_date,
sandbox_instance_id=sandbox_instance_id,
ai_mode=ai_mode or AI_MODE_LIVE,
status=status or "active",
)
def namespace_ai_target_id(site_id: int, target_id: str, conn: Any | None = None) -> str:
"""按当前上下文转换 AI cache target_id。
前端和调用方继续使用原始 target_id沙箱命名空间在后端统一处理。
"""
ctx = get_runtime_context(site_id, conn=conn)
if not ctx.is_sandbox or not ctx.sandbox_instance_id:
return target_id
return f"{ctx.sandbox_instance_id}:{target_id}"
def task_runtime_filter(
site_id: int,
*,
alias: str = "",
conn: Any | None = None,
) -> tuple[str, list[Any]]:
"""返回 coach_tasks 等表的运行上下文过滤条件。"""
ctx = get_runtime_context(site_id, conn=conn)
prefix = f"{alias}." if alias else ""
if ctx.is_sandbox and ctx.sandbox_instance_id:
return (
f" AND {prefix}runtime_mode = %s AND {prefix}sandbox_instance_id = %s",
[MODE_SANDBOX, ctx.sandbox_instance_id],
)
return (
f" AND COALESCE({prefix}runtime_mode, 'live') = %s "
f"AND COALESCE({prefix}sandbox_instance_id, %s) = %s",
[MODE_LIVE, LIVE_INSTANCE_ID, LIVE_INSTANCE_ID],
)
def runtime_insert_columns(site_id: int, conn: Any | None = None) -> tuple[str, str, list[Any]]:
"""返回 INSERT SQL 片段:列名、占位符和值。"""
ctx = get_runtime_context(site_id, conn=conn)
return (
"runtime_mode, sandbox_instance_id",
"%s, %s",
[
MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE,
ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID,
],
)
def runtime_update_assignments(site_id: int, conn: Any | None = None) -> tuple[str, list[Any]]:
"""返回 UPDATE SQL 片段,用于把运行上下文写回已有记录。"""
ctx = get_runtime_context(site_id, conn=conn)
return (
"runtime_mode = %s, sandbox_instance_id = %s",
[
MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE,
ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID,
],
)
def as_runtime_now_param(site_id: int, conn: Any | None = None) -> datetime:
"""返回可传给 SQL 的业务当前时间。"""
return get_runtime_context(site_id, conn=conn).business_now
def as_runtime_today_param(site_id: int, conn: Any | None = None) -> date:
"""返回可传给 SQL 的业务当前日期。"""
return get_runtime_context(site_id, conn=conn).business_date
def as_runtime_year_month_param(site_id: int, conn: Any | None = None) -> str:
"""返回 'YYYY-MM' 形式的业务年月,用于 performance 等月度查询。"""
bd = get_runtime_context(site_id, conn=conn).business_date
return f"{bd.year:04d}-{bd.month:02d}"
def as_runtime_business_now_str(site_id: int, conn: Any | None = None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
"""返回业务当前时间的格式化字符串,用于 AI prompts 中的 current_time。"""
return get_runtime_context(site_id, conn=conn).business_now.strftime(fmt)
def business_date_upper_bound_sql(
site_id: int,
*,
column: str,
alias: str = "",
cast: str | None = None,
conn: Any | None = None,
) -> tuple[str, list[Any]]:
"""返回业务日上界 SQL 片段。
sandbox 模式下,强制把 ``column`` 限制在业务日及之前(避免读到「未来」数据)。
live 模式下返回空片段,不影响任何逻辑。
cast 用于把 timestamp/timestamptz 列裁剪成日期再比较,例如 ``cast='date'``。
"""
ctx = get_runtime_context(site_id, conn=conn)
if not ctx.is_sandbox:
return ("", [])
prefix = f"{alias}." if alias else ""
expr = f"{prefix}{column}"
if cast:
expr = f"({expr})::{cast}"
return (f" AND {expr} <= %s", [ctx.business_date])
def apply_runtime_session_vars(conn: Any, ctx: RuntimeContext | None = None, *, site_id: int | None = None) -> None:
"""在已有数据库连接上设置 ``app.current_business_date`` 等 GUC 变量。
供 RLS 视图层C 方案)使用:视图通过 ``current_setting('app.current_business_date', true)``
读取业务日,再对事实/维度表做日期上界裁剪。
无论 live / sandbox 都设置该变量live 下视图仍按真实 ``CURRENT_DATE`` 行为。
"""
if ctx is None:
if site_id is None:
raise ValueError("apply_runtime_session_vars 需要 ctx 或 site_id 之一")
ctx = get_runtime_context(site_id, conn=conn)
bd = ctx.business_date.isoformat()
mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
with conn.cursor() as cur:
cur.execute(
"SELECT set_config('app.current_business_date', %s, true), "
"set_config('app.current_runtime_mode', %s, true)",
(bd, mode),
)

View File

@@ -11,6 +11,7 @@
import json
import logging
from app.services.runtime_context import as_runtime_now_param, task_runtime_filter
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
@@ -71,32 +72,42 @@ def run() -> dict:
conn = _get_connection()
try:
# 查询所有已过期的 active 任务
# 查询所有已过期的 active 任务。沙箱模式按业务时间判断,并只处理当前运行实例。
expired_tasks = []
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, task_type
FROM biz.coach_tasks
WHERE expires_at IS NOT NULL
AND expires_at < NOW()
AND status = 'active'
"""
)
expired_tasks = cur.fetchall()
cur.execute("SELECT site_id FROM biz.sites WHERE is_active = true")
site_ids = [row[0] for row in cur.fetchall()]
for site_id in site_ids:
runtime_now = as_runtime_now_param(site_id, conn=conn)
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
cur.execute(
f"""
SELECT id, task_type, site_id
FROM biz.coach_tasks
WHERE site_id = %s
{runtime_clause}
AND expires_at IS NOT NULL
AND expires_at < %s
AND status = 'active'
""",
[site_id, *runtime_params, runtime_now],
)
expired_tasks.extend(cur.fetchall())
conn.commit()
# 逐条处理,每条独立事务
for task_id, task_type in expired_tasks:
for task_id, task_type, site_id in expired_tasks:
try:
runtime_now = as_runtime_now_param(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
SET status = 'inactive', updated_at = %s
WHERE id = %s AND status = 'active'
""",
(task_id,),
(runtime_now, task_id),
)
_insert_history(
cur,

View File

@@ -41,6 +41,13 @@ from dataclasses import dataclass
from enum import IntEnum
from app.trace.decorators import trace_service
from app.services.runtime_context import (
LIVE_INSTANCE_ID,
MODE_LIVE,
MODE_SANDBOX,
get_runtime_context,
task_runtime_filter,
)
class TaskPriority(IntEnum):
@@ -189,6 +196,14 @@ import logging
logger = logging.getLogger(__name__)
def _runtime_values(conn, site_id: int):
"""返回当前门店任务写入所需的运行上下文值。"""
ctx = get_runtime_context(site_id, conn=conn)
mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
instance_id = ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID
return ctx, mode, instance_id, ctx.business_now
def _get_connection():
"""延迟导入 get_connection避免纯函数测试时触发模块级导入失败。"""
from app.database import get_connection
@@ -210,7 +225,10 @@ def run() -> dict:
返回: {"created": int, "replaced": int, "skipped": int, "transferred": int}
"""
from datetime import datetime, timezone
stats = {"created": 0, "replaced": 0, "skipped": 0, "transferred": 0}
run_started_at = datetime.now(timezone.utc)
conn = _get_connection()
try:
@@ -265,6 +283,14 @@ def run() -> dict:
)
conn.commit()
# ── 6. 触发 AI 消费事件 — 对本次 run 新建的任务逐个触发 ai_consumption_settled
# 仅按 created_at >= run_started_at 过滤(精确锁定本次新建),避免误触发历史任务。
# dispatcher 内部按 (event, member_id, site_id, date) 去重,重复触发无害。
try:
_fire_ai_consumption_events(conn, run_started_at)
except Exception:
logger.exception("ai_consumption_settled 事件触发失败(不影响任务生成主流程)")
finally:
conn.close()
@@ -278,6 +304,54 @@ def run() -> dict:
return stats
def _fire_ai_consumption_events(conn, run_started_at) -> None:
"""查询本次 run 新建的任务,对每条 (site_id, member_id, assistant_id) 触发 ai_consumption_settled。
has_assistant 恒为 True任务必然绑定助教
dispatcher 去重确保每 member 每天 AI 链路至多跑一次。
"""
from app.services.trigger_scheduler import fire_event
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT site_id, member_id, assistant_id
FROM biz.coach_tasks
WHERE created_at >= %s
AND member_id IS NOT NULL
AND assistant_id IS NOT NULL
""",
(run_started_at,),
)
pairs = cur.fetchall()
conn.commit()
triggered = 0
for row in pairs:
site_id, member_id, assistant_id = row[0], row[1], row[2]
try:
fire_event(
"ai_consumption_settled",
{
"site_id": site_id,
"member_id": member_id,
"assistant_id": assistant_id,
"has_assistant": True,
},
)
triggered += 1
except Exception:
logger.exception(
"触发 ai_consumption_settled 失败: site_id=%s member_id=%s",
site_id, member_id,
)
logger.info(
"ai_consumption_settled 触发完成: 新建任务去重后 %d 个 member成功触发 %d",
len(pairs), triggered,
)
def _run_for_site(conn, site_id: int, stats: dict) -> None:
"""
单门店处理流程。
@@ -766,9 +840,10 @@ def _run_transfer_check(
w_ms = params["transfer_score_w_ms"]
w_ml = params["transfer_score_w_ml"]
from datetime import datetime, timezone
from app.services.runtime_context import as_runtime_now_param
now = datetime.now(timezone.utc)
# 业务时间锚sandbox 模式下用 business_now避免按真实时间把已转移很久的任务再算成候选
now = as_runtime_now_param(site_id, conn=conn)
for task_id, from_assistant_id, member_id, task_type, transfer_count, created_at in candidates:
# CHANGE 2026-03-29 | 用升级倍数判定是否触发转移
@@ -805,9 +880,7 @@ def _run_transfer_check(
)
entry_dates = {r[0]: r[1] for r in cur.fetchall()}
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
# 沿用上方 business_now避免「真实今天」的入驻时间保护
eligible = []
for a in pool:
aid = a["assistant_id"]

View File

@@ -37,6 +37,7 @@ from decimal import Decimal
from fastapi import HTTPException
from app.services import fdw_queries
from app.services.runtime_context import get_runtime_context, task_runtime_filter
from app.services.task_generator import compute_heart_icon
from app.trace.decorators import trace_service
@@ -114,15 +115,17 @@ def _verify_task_ownership(
- 不属于当前助教 → 403
- required_status 不匹配 → 409
"""
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, task_type, status, is_pinned, abandon_reason,
assistant_id, site_id
FROM biz.coach_tasks
WHERE id = %s
{runtime_clause}
""",
(task_id,),
[task_id, *runtime_params],
)
row = cur.fetchone()
@@ -166,22 +169,24 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
assistant_id = _get_assistant_id(conn, user_id, site_id)
# 查询有效 + 已放弃任务abandoned 排最后)
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, task_type, status, priority_score, is_pinned,
expires_at, created_at, member_id, abandon_reason
FROM biz.coach_tasks
WHERE site_id = %s
AND assistant_id = %s
AND status IN ('active', 'abandoned')
{runtime_clause}
ORDER BY
CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC,
is_pinned DESC,
priority_score DESC NULLS LAST,
created_at ASC
""",
(site_id, assistant_id),
[site_id, assistant_id, *runtime_params],
)
tasks = cur.fetchall()
conn.commit()
@@ -605,8 +610,9 @@ async def get_task_list_v2(
# 构建排除条件relationship_building + member_id 不在 RS 范围内
# 当排除列表为空时不加额外条件
exclude_clause = ""
query_params_count: list = [site_id, assistant_id, db_status]
query_params_page: list = [site_id, assistant_id, db_status]
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
query_params_count: list = [site_id, assistant_id, db_status, *runtime_params]
query_params_page: list = [site_id, assistant_id, db_status, *runtime_params]
if rb_exclude_member_ids:
exclude_clause = (
" AND NOT (task_type = 'relationship_building' AND member_id = ANY(%s))"
@@ -621,6 +627,7 @@ async def get_task_list_v2(
SELECT COUNT(*)
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND status = %s
{runtime_clause}
{exclude_clause}
""",
query_params_count,
@@ -636,6 +643,7 @@ async def get_task_list_v2(
expires_at, created_at, member_id, abandon_reason
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND status = %s
{runtime_clause}
{exclude_clause}
ORDER BY is_pinned DESC,
priority_score DESC NULLS LAST,
@@ -669,9 +677,11 @@ async def get_task_list_v2(
recent60d_map: dict[int, dict] = {}
batch_data: dict | None = None
try:
from app.services.runtime_context import as_runtime_today_param
_ref_date = as_runtime_today_param(site_id, conn=conn)
batch_data = fdw_queries.batch_query_for_task_list(
conn, site_id, assistant_id, member_ids,
datetime.now().year, datetime.now().month,
_ref_date.year, _ref_date.month,
)
member_info_map = batch_data["member_info"]
balance_map = batch_data["balance"]
@@ -685,7 +695,11 @@ async def get_task_list_v2(
# ── 6. 查询 ai_cache 获取 aiSuggestion优雅降级 ──
ai_suggestion_map: dict[int, str] = {}
try:
member_id_strs = [str(mid) for mid in member_ids]
runtime_ctx = get_runtime_context(site_id, conn=conn)
if runtime_ctx.is_sandbox and runtime_ctx.sandbox_instance_id:
member_id_strs = [f"{runtime_ctx.sandbox_instance_id}:{mid}" for mid in member_ids]
else:
member_id_strs = [str(mid) for mid in member_ids]
with conn.cursor() as cur:
cur.execute(
"""
@@ -706,7 +720,8 @@ async def get_task_list_v2(
result = row[1] if isinstance(row[1], dict) else {}
summary = result.get("summary", "")
if summary:
ai_suggestion_map[int(target_id_str)] = summary
raw_target = target_id_str.split(":", 1)[-1]
ai_suggestion_map[int(raw_target)] = summary
conn.commit()
except Exception:
logger.warning("查询 ai_cache aiSuggestion 失败", exc_info=True)
@@ -802,8 +817,11 @@ def build_performance_summary(
当 batch_data 为 None 时(如无任务的空列表场景),回退到独立查询。
课时/档位/客户数从 monthly_summary每日更新取实时数据
不再依赖月初结算的 salary_calc。收入仍从 salary_calc 取(如有)。
CHANGE 2026-05-02 | now 改用 RuntimeContext.business_date沙箱不读「未来」月份。
"""
now = datetime.now()
from app.services.runtime_context import as_runtime_today_param
now = as_runtime_today_param(site_id, conn=conn)
year, month = now.year, now.month
if batch_data:
@@ -971,15 +989,17 @@ async def get_task_by_member(
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, task_type
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
AND status = 'active'
{runtime_clause}
""",
(site_id, assistant_id, member_id),
[site_id, assistant_id, member_id, *runtime_params],
)
rows = cur.fetchall()
@@ -1020,16 +1040,18 @@ async def get_task_detail(
assistant_id = _get_assistant_id(conn, user_id, site_id)
# ── 1. 查询任务基础信息 ──
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, task_type, status, priority_score, is_pinned,
expires_at, created_at, member_id, abandon_reason,
assistant_id, site_id
FROM biz.coach_tasks
WHERE id = %s
{runtime_clause}
""",
(task_id,),
[task_id, *runtime_params],
)
row = cur.fetchone()
@@ -1090,6 +1112,12 @@ async def get_task_detail(
# ── 3. 查询维客线索 ──
retention_clues = []
try:
runtime_ctx = get_runtime_context(site_id, conn=conn)
member_target_id = (
f"{runtime_ctx.sandbox_instance_id}:{member_id}"
if runtime_ctx.is_sandbox and runtime_ctx.sandbox_instance_id
else str(member_id)
)
with conn.cursor() as cur:
cur.execute(
"""
@@ -1136,7 +1164,7 @@ async def get_task_detail(
AND cache_type IN ('app4_analysis', 'app5_talking_points')
ORDER BY created_at DESC
""",
(str(member_id), site_id),
(member_target_id, site_id),
)
seen_types: set[str] = set()
for cache_row in cur.fetchall():
@@ -1173,8 +1201,10 @@ async def get_task_detail(
# CHANGE 2026-03-25 | 统计范围近60天列表不限
# 预估规则:当月且日期 ≤ 5号
from datetime import date, timedelta
today = date.today()
# CHANGE 2026-05-02 | today 改用 business_date沙箱不读「未来」60 天
from datetime import timedelta
from app.services.runtime_context import as_runtime_today_param
today = as_runtime_today_param(site_id, conn=conn)
cutoff_60d = today - timedelta(days=60)
is_estimate_month = today.day <= 5

View File

@@ -10,7 +10,10 @@
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import threading
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
@@ -19,6 +22,34 @@ from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
def _invoke_handler(handler: Callable, **kwargs: Any) -> Any:
"""统一调用 handler自动识别 sync / async。
- sync handler直接返回结果
- async handler
- 当前线程有 running loop → loop.create_task(coro),后台异步执行
- 当前线程无 running loop → 新起 daemon 线程跑 asyncio.run(coro),不阻塞调用方
说明fire_event / check_scheduled_jobs 是 sync 函数,但部分 handler
(如 dispatcher 注册的 AI 事件 handler是 async def本包装器保证正确调度。
"""
result = handler(**kwargs)
if not inspect.iscoroutine(result):
return result
try:
loop = asyncio.get_running_loop()
loop.create_task(result)
return None
except RuntimeError:
# 同步线程(无 running loop用后台线程异步执行 coroutine不阻塞调用方
threading.Thread(
target=lambda coro=result: asyncio.run(coro),
daemon=True,
).start()
return None
def _get_connection():
"""延迟导入 get_connection避免纯函数测试时触发模块级导入失败。"""
from app.database import get_connection
@@ -89,7 +120,8 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
continue
try:
# 将 job_id 传入 handlerhandler 在最终 commit 前更新 last_run_at
handler(payload=payload, job_id=job_id)
# async handler 经 _invoke_handler 自动调度
_invoke_handler(handler, payload=payload, job_id=job_id)
executed += 1
except Exception:
logger.exception("触发器 %s 执行失败", job_name)
@@ -136,7 +168,8 @@ def check_scheduled_jobs() -> int:
continue
try:
# cron/interval handler 接受 conn + job_id在最终 commit 前更新时间戳
handler(conn=conn, job_id=job_id)
# async handler 经 _invoke_handler 自动调度
_invoke_handler(handler, conn=conn, job_id=job_id)
# 计算 next_run_at 并更新(在 handler commit 后的新事务中)
next_run = _calculate_next_run(trigger_condition, trigger_config)
with conn.cursor() as cur:
@@ -276,7 +309,7 @@ def run_job_by_id(job_id: int) -> dict:
return {"success": False, "message": f"任务 {job_name} 未注册处理器"}
try:
handler()
_invoke_handler(handler)
# 更新 last_run_at 和 next_run_at
next_run = _calculate_next_run(trigger_condition, trigger_config)
with conn.cursor() as cur:

View File

@@ -0,0 +1,80 @@
"""AI 事件 WebSocket 推送端点。
提供:
- /ws/ai-cache/{site_id} — 缓存更新 / 失效事件
- /ws/ai-alerts/{site_id} — AI 告警事件Phase 3.1
协议:
- 客户端连接 → 服务端 accept → 订阅 EventBus → 持续 send_json 事件
- 事件格式:{"type": "cache_updated|cache_invalidated|alert_created|...", "site_id": int, "payload": {...}}
- 服务端关闭或客户端断开时清理订阅
用 site_id=-1 表示全局订阅收所有门店事件admin-web 全局监控用)。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from ..ai.event_bus import AIEvent, get_event_bus
logger = logging.getLogger(__name__)
ws_router = APIRouter()
@ws_router.websocket("/ws/ai-cache/{site_id}")
async def ws_ai_cache(websocket: WebSocket, site_id: int) -> None:
"""AI 缓存事件推送。
site_id=-1 表示订阅全局(收所有门店的 cache_updated / cache_invalidated
"""
await _serve_event_stream(websocket, site_id, endpoint="ai-cache")
@ws_router.websocket("/ws/ai-alerts/{site_id}")
async def ws_ai_alerts(websocket: WebSocket, site_id: int) -> None:
"""AI 告警事件推送Phase 3.1)。
site_id=-1 表示订阅全局告警。
事件 type: alert_created / alert_updated / budget_exceeded / circuit_opened。
"""
await _serve_event_stream(websocket, site_id, endpoint="ai-alerts")
async def _serve_event_stream(
websocket: WebSocket, site_id: int, endpoint: str,
) -> None:
"""共享事件流处理逻辑。"""
await websocket.accept()
# -1 映射为全局订阅None
subscribe_key: int | None = None if site_id == -1 else site_id
logger.info(
"WS %s 连接建立: site_id=%s", endpoint, subscribe_key if subscribe_key else "ALL",
)
bus = get_event_bus()
queue = await bus.subscribe(subscribe_key)
try:
while True:
event = await queue.get()
if event is None:
break
await websocket.send_json({
"type": event.type,
"site_id": event.site_id,
"payload": event.payload,
})
except WebSocketDisconnect:
logger.info("WS %s 客户端断开: site_id=%s", endpoint, subscribe_key)
except Exception:
logger.exception("WS %s 异常: site_id=%s", endpoint, subscribe_key)
finally:
await bus.unsubscribe(subscribe_key, queue)
try:
await websocket.close()
except Exception:
pass

View File

@@ -2,3 +2,4 @@
testpaths = tests
pythonpath = .
asyncio_mode = auto
norecursedirs = _archived _deleted

View File

@@ -24,6 +24,7 @@
- 非 all 行现金流/卡消费/充值字段 = 0
- hall 行 = 各具体区域之和(历史兼容)
- all 行 = 各具体区域之和(收入/优惠),现金流/充值/卡消费来自 dws_finance_daily_summary
无台桌订单和补时长等 all-only 台区只合入 all不合入具体区域
- settle_type IN (1, 3) 过滤
- discount_gift_card 使用赠送卡消费金额口径
@@ -107,6 +108,9 @@ _COUNT_FIELDS = {"order_count", "member_order_count"}
_ZERO = Decimal("0")
# 已知不属于看板 7 个具体区域、但应合入 all 的物理台区。
_ALL_ONLY_AREA_NAMES = {"补时长", "虚拟台"}
class FinanceAreaDailyTask(FinanceBaseTask):
"""
@@ -177,6 +181,7 @@ class FinanceAreaDailyTask(FinanceBaseTask):
sql = f"""
SELECT
{biz_expr} AS stat_date,
sh.table_id AS table_id,
dt.site_table_area_name AS area_name,
sh.settle_type,
-- 收入
@@ -378,8 +383,9 @@ def transform_area_daily(
)
# 收集所有涉及的日期
all_dates: set[date] = set()
# 未知区域名称计数(汇总后一次性输出,避免逐行 warning 产生大量日志噪音)
# 未知/无具体区域计数(汇总后一次性输出,避免逐行日志噪音)
_unknown_area_counts: Dict[str, int] = defaultdict(int)
_all_only_area_counts: Dict[str, int] = defaultdict(int)
for row in settlement_rows:
sd = row.get("stat_date")
@@ -393,10 +399,15 @@ def transform_area_daily(
continue
area_name = row.get("area_name")
table_id = row.get("table_id")
area_code = resolve_area_code(area_name)
if area_code is None:
_unknown_area_counts[str(area_name)] += 1
unmatched_label = _format_unmatched_area_label(area_name, table_id)
if _is_all_only_area(area_name, table_id):
_all_only_area_counts[unmatched_label] += 1
else:
_unknown_area_counts[unmatched_label] += 1
# 提取金额
table_fee = safe_decimal_fn(row.get("table_fee_amount", 0))
@@ -479,11 +490,20 @@ def transform_area_daily(
for k, v in fields.items():
bucket[k] = bucket[k] + v
# 汇总输出未知区域名称(避免逐行 warning 刷屏)
# 汇总输出 all-only 区域(无台桌订单、补时长等),这些记录合入 all 属正常口径。
if _all_only_area_counts:
summary = ", ".join(f"'{k}': {v}" for k, v in _all_only_area_counts.items())
logger.info(
"DWS_FINANCE_AREA_DAILY: 共 %d 条结算单无具体区域(已计入 all不计入任何具体区域: %s",
sum(_all_only_area_counts.values()),
summary,
)
# 汇总输出真正未知区域名称(避免逐行 warning 刷屏)
if _unknown_area_counts:
summary = ", ".join(f"'{k}': {v}" for k, v in _unknown_area_counts.items())
logger.warning(
"DWS_FINANCE_AREA_DAILY: 共 %d 条结算单区域未匹配(已计入 all 但不计入任何具体区域): %s",
"DWS_FINANCE_AREA_DAILY: 共 %d 条结算单区域未匹配(已计入 all 但不计入任何具体区域,请检查 dim_table/AREA_LABEL_MAP: %s",
sum(_unknown_area_counts.values()),
summary,
)
@@ -618,4 +638,42 @@ def _safe_decimal(value: Any, default: Decimal = _ZERO) -> Decimal:
return default
def _is_all_only_area(area_name: Any, table_id: Any) -> bool:
"""判断结算单是否属于无具体区域但应合入 all 的正常口径。
CHANGE 2026-05-02 | 扩大豁免规则,避免噪音 WARNING
- "补时长" / "虚拟台" 的带数字/空格变体(如 "补时长2""虚拟台 1")也算 all-only。
- 维表 site_table_area_name 为空NULL但有 table_id 的脏数据,归入 all-only INFO
因为这通常是 dim_table SCD2 缺区域名而非真正映射缺口;金额仍合入 all 不丢失。
真正的「未知非空区域名」(如新店自定义命名未在 AREA_LABEL_MAP 中)才进 WARNING。
"""
if area_name is None:
# 无 table_id本来就没台桌正常 all-only
# 有 table_id维表区域名缺失作为 dim_table 数据问题,仍归 all-only 但保留可观测性INFO 行会带 'table_id=… None' 标签)
return True
if not isinstance(area_name, str):
return False
name = area_name.strip()
if not name:
return True
if name in _ALL_ONLY_AREA_NAMES:
return True
# 形如 "补时长2"、"补时长 3"、"虚拟台4" 等编号变体
for prefix in _ALL_ONLY_AREA_NAMES:
if name.startswith(prefix):
tail = name[len(prefix):].strip()
if not tail or tail.isdigit():
return True
return False
def _format_unmatched_area_label(area_name: Any, table_id: Any) -> str:
"""格式化未匹配区域日志标签,区分无台桌订单和维表缺口。"""
if area_name is None and not table_id:
return "无台桌"
if area_name is None:
return f"table_id={table_id}: None"
return str(area_name)
__all__ = ["FinanceAreaDailyTask", "transform_area_daily"]

View File

@@ -42,7 +42,10 @@ load_dotenv(_REPO_ROOT / ".env", override=False)
logger = logging.getLogger(__name__)
_TIMEOUT = (5, 30)
# CHANGE 2026-05-02 | 旧值 (5, 30) 在 recall_completion_check / task_generator 这种长任务下
# 经常 30s 读超时(实际处理 ~33s 以上)。临时止血提到 600s 与 flow_runner 对齐;
# 长期方案是后端 /api/internal/run-job 改异步入队(参见 docs/database/changes/2026-05-02__sandbox_complete_refactor.md 已知未覆盖)
_TIMEOUT = (10, 600)
# HTTP 模式<E6A8A1><E5BC8F><EFBFBD>按顺序执行的后端任务
_JOB_SEQUENCE = [

View File

@@ -20,13 +20,47 @@ Component({
type: Number,
value: 200,
},
/**
* Phase 2.3来源页面标识sourcePage用于后端注入 page_context。
* 取值参考 backend page_context.py 的 SUPPORTED_PAGE_TYPES
* board-finance / board-coach / board-customer / performance / task-list / my-profile 等。
* 为空时不传入 chat 页。
*/
sourcePage: {
type: String,
value: '',
},
/**
* Phase 2.3页面筛选参数board-* 页面的 timeDimension/dimension/areaFilter 等),
* 将作为 JSON 字符串附加到 url在 chat 页面解析后随 SSE 请求发给后端。
*/
pageFilters: {
type: Object,
value: null as Record<string, string> | null,
},
},
methods: {
onTap() {
let url = this.data.targetUrl
const params: string[] = []
if (this.data.customerId) {
url += `?customerId=${this.data.customerId}`
params.push(`customerId=${encodeURIComponent(this.data.customerId)}`)
}
if (this.data.sourcePage) {
params.push(`sourcePage=${encodeURIComponent(this.data.sourcePage)}`)
}
if (this.data.pageFilters && Object.keys(this.data.pageFilters).length > 0) {
try {
params.push(
`pageFilters=${encodeURIComponent(JSON.stringify(this.data.pageFilters))}`,
)
} catch {
// 非法 filters 忽略,不影响跳转
}
}
if (params.length > 0) {
url += (url.includes('?') ? '&' : '?') + params.join('&')
}
wx.navigateTo({
url,

View File

@@ -203,6 +203,8 @@
<!-- AI 洞察 -->
<!-- CHANGE 2026-03-12 | intent: H5 原型使用 SVG 机器人图标,不可用 emoji 替代;规范要求内联 SVG 导出为文件用 image 引用 -->
<!-- CHANGE 2026-03-21 | P13 T6.1: AI 洞察改为动态渲染,移除硬编码文案 -->
<!-- CHANGE 2026-04-22 | AI 洞察改版两段式dim标题 + 正文第3条起省略为1行加"查看全部"按钮 + 覆盖大弹窗 -->
<!-- CHANGE 2026-04-22 seq11/12 置顶AI 洞察区首屏为"本期总结"(健康度 + 跟踪),下方为明细 -->
<view class="ai-insight-section">
<view class="ai-insight-header">
<view class="ai-insight-icon">
@@ -210,11 +212,108 @@
</view>
<text class="ai-insight-title">AI 智能洞察</text>
</view>
<view class="ai-insight-body" wx:if="{{aiInsights.length > 0}}">
<text class="ai-insight-line" wx:for="{{aiInsights}}" wx:key="index"><text class="ai-insight-dim">{{item.icon}} </text>{{item.text}}</text>
<!-- 本期总结卡片seq11(健康度评级) + seq12(跟踪指标) -->
<view class="ai-summary-card ai-summary-card--{{summaryLightType || 'neutral'}}" wx:if="{{aiInsightSummary.evaluation || aiInsightSummary.tracking}}">
<view class="ai-summary-head">
<view class="ai-summary-badge ai-summary-badge--{{summaryLightType || 'neutral'}}" wx:if="{{summaryLightLabel}}">
<text>{{summaryLightLabel}}</text>
</view>
<text class="ai-summary-head-title">本期总结</text>
</view>
<!-- 2026-04-22 v4evaluation title 与顶部徽章语义重复,隐藏 title 仅展示 body -->
<view class="ai-summary-block" wx:if="{{aiInsightSummary.evaluation}}">
<view class="ai-summary-block-body ai-summary-block-body-clamp">
<text wx:for="{{aiInsightSummary.evaluation.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
<view class="ai-summary-divider" wx:if="{{aiInsightSummary.evaluation && aiInsightSummary.tracking}}"></view>
<view class="ai-summary-block ai-summary-block--tracking" wx:if="{{aiInsightSummary.tracking}}">
<text class="ai-summary-block-title">⏰ {{aiInsightSummary.tracking.title}}</text>
<view class="ai-summary-block-body ai-summary-block-body-clamp">
<text wx:for="{{aiInsightSummary.tracking.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
</view>
<view class="ai-insight-body" wx:else>
<text class="ai-insight-line ai-insight-dim">暂无洞察数据</text>
<view class="ai-insight-body" wx:if="{{aiInsightDetails.length > 0}}">
<view class="ai-insight-details-label" wx:if="{{aiInsightSummary.evaluation || aiInsightSummary.tracking}}">
<text class="ai-insight-details-label-text">分板块明细洞察 · 仅展示前 3 条</text>
</view>
<!-- 2026-04-22 v3seq 1/2/3 统一展示"标题 + 单行省略正文",详情看弹窗 -->
<block wx:for="{{aiInsightDetails}}" wx:key="index" wx:if="{{index < 3}}">
<view class="ai-insight-item">
<view class="ai-insight-item-title">
<text class="ai-insight-item-seq">{{index + 1}}</text>
<text class="ai-insight-item-name">{{item.title}}</text>
</view>
<view class="ai-insight-item-body ai-insight-item-body-ellipsis">
<text wx:for="{{item.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
</block>
<!-- 2026-04-22 v3只要有洞察就显示"查看全部"按钮,引导进弹窗看完整内容 -->
<view class="ai-insight-more" wx:if="{{aiInsightDetails.length > 0 || aiInsightSummary.evaluation}}" bindtap="openAllInsights" hover-class="ai-insight-more-hover">
<text class="ai-insight-more-text">查看全部 AI 洞察 </text>
</view>
</view>
<view class="ai-insight-body" wx:elif="{{!aiInsightSummary.evaluation && !aiInsightSummary.tracking}}">
<view class="ai-insight-item-body ai-insight-dim">暂无洞察数据</view>
</view>
</view>
<!-- AI 洞察全部查看弹窗:覆盖除底部 tab 外整个页面header / 可滚动 body / 底部通栏按钮 -->
<view class="ai-modal-mask" wx:if="{{aiInsightsModalVisible}}" bindtap="closeAllInsights" catchtouchmove="_noop">
<view class="ai-modal" catchtap="_noop">
<view class="ai-modal-header">
<view class="ai-modal-title-wrap">
<view class="ai-insight-icon">
<image src="/assets/icons/ai-robot.svg" mode="aspectFit" class="ai-insight-icon-img" />
</view>
<text class="ai-modal-title">AI 智能洞察 · 共 {{aiInsights.length}} 条</text>
</view>
<view class="ai-modal-close" bindtap="closeAllInsights" hover-class="ai-modal-close-hover">
<text class="ai-modal-close-icon">✕</text>
</view>
</view>
<scroll-view scroll-y="{{true}}" class="ai-modal-body" enhanced="{{true}}" show-scrollbar="{{false}}" bounces="{{true}}">
<!-- 弹窗顶部本期总结seq11+seq12- 同款 summary-card -->
<view class="ai-summary-card ai-summary-card--{{summaryLightType || 'neutral'}} ai-summary-card--modal" wx:if="{{aiInsightSummary.evaluation || aiInsightSummary.tracking}}">
<view class="ai-summary-head">
<view class="ai-summary-badge ai-summary-badge--{{summaryLightType || 'neutral'}}" wx:if="{{summaryLightLabel}}">
<text>{{summaryLightLabel}}</text>
</view>
<text class="ai-summary-head-title">本期总结</text>
</view>
<view class="ai-summary-block" wx:if="{{aiInsightSummary.evaluation}}">
<view class="ai-summary-block-body">
<text wx:for="{{aiInsightSummary.evaluation.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
<view class="ai-summary-divider" wx:if="{{aiInsightSummary.evaluation && aiInsightSummary.tracking}}"></view>
<view class="ai-summary-block ai-summary-block--tracking" wx:if="{{aiInsightSummary.tracking}}">
<text class="ai-summary-block-title">⏰ {{aiInsightSummary.tracking.title}}</text>
<view class="ai-summary-block-body">
<text wx:for="{{aiInsightSummary.tracking.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
</view>
<view class="ai-modal-details-label" wx:if="{{(aiInsightSummary.evaluation || aiInsightSummary.tracking) && aiInsightDetails.length > 0}}">
<text class="ai-modal-details-label-text">分板块明细洞察</text>
</view>
<view class="ai-modal-item" wx:for="{{aiInsightDetails}}" wx:key="index">
<view class="ai-modal-item-title">
<text class="ai-modal-item-seq">{{index + 1}}</text>
<text class="ai-modal-item-name">{{item.title}}</text>
</view>
<view class="ai-modal-item-body">
<text wx:for="{{item.bodySegs}}" wx:key="index" wx:for-item="seg" class="md-seg {{seg.bold ? 'md-bold' : ''}} {{seg.italic ? 'md-italic' : ''}}">{{seg.text}}</text>
</view>
</view>
<view class="ai-modal-footer-space"></view>
</scroll-view>
<view class="ai-modal-footer" bindtap="closeAllInsights" hover-class="ai-modal-footer-hover">关闭</view>
</view>
</view>
</view>

View File

@@ -492,7 +492,7 @@ AI_CHANGELOG
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 22rpx;
margin-bottom: 30rpx;
}
/* CHANGE 2026-03-12 | intent: H5 原型 AI 图标为 SVG 机器人24×24 → 42rpx不可用 emoji 替代 */
@@ -545,6 +545,357 @@ AI_CHANGELOG
color: rgba(255, 255, 255, 0.85);
}
/* CHANGE 2026-04-22 v2 | AI 洞察列表项:与弹窗同款(序号徽章 + 标题 + 缩进正文) */
.ai-insight-item {
padding: 6rpx 0 10rpx 0;
}
.ai-insight-item-title {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.ai-insight-item-seq {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 10rpx;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 20rpx;
font-weight: 600;
}
.ai-insight-item-name {
font-size: 26rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-insight-item-body {
display: block;
font-size: 24rpx;
line-height: 36rpx;
color: rgba(255, 255, 255, 0.78);
text-indent: 48rpx;
}
.ai-insight-item-body-ellipsis {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
}
/* ===== 2026-04-22 seq11/12 置顶:本期总结(轻量版) ===== */
/* 不用卡片容器,直接嵌入 AI 洞察区,用彩色小点 + 淡分隔区分层级 */
.ai-summary-card {
margin: 0 24rpx 18rpx 0;
padding: 0 0 18rpx 0;
border-bottom: 2rpx dashed rgba(255, 255, 255, 0.1);
}
.ai-summary-card--modal {
margin: 0 0 14rpx 0;
}
/* 去掉左侧彩条,保留类名备用(无样式即不渲染) */
.ai-summary-card--green,
.ai-summary-card--yellow,
.ai-summary-card--red,
.ai-summary-card--neutral {
background: transparent;
}
.ai-summary-head {
display: flex;
align-items: baseline; /* 徽章与"本期总结"按文字基线对齐(字号不同时看起来贴底) */
gap: 10rpx;
margin-bottom: 14rpx;
}
/* 徽章:去胶囊底,纯色粗字强调三色灯级别 */
.ai-summary-badge {
display: inline-flex;
align-items: center;
font-size: 30rpx;
font-weight: 700;
letter-spacing: 1rpx;
padding: 0;
background: transparent !important;
box-shadow: none !important;
}
.ai-summary-badge--green { color: #4ade80; }
.ai-summary-badge--yellow { color: #facc15; }
.ai-summary-badge--red { color: #f87171; }
.ai-summary-badge--neutral { color: rgba(255, 255, 255, 0.6); }
.ai-summary-head-title {
font-size: 22rpx;
font-weight: 400;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 1rpx;
}
.ai-summary-block {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.ai-summary-block-title {
font-size: 25rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
line-height: 36rpx;
}
.ai-summary-block-body {
font-size: 24rpx;
line-height: 36rpx;
color: rgba(255, 255, 255, 0.72);
}
/* 2026-04-22 v3总结区 body 2 行省略,突出要点 */
.ai-summary-block-body-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-summary-block--tracking {
margin-top: 14rpx;
}
.ai-summary-block--tracking .ai-summary-block-title {
color: rgba(251, 191, 36, 0.85);
font-weight: 500;
}
.ai-summary-divider {
display: none;
}
/* "分板块明细洞察"分组标签 */
.ai-insight-details-label {
padding: 2rpx 0 12rpx 0;
}
.ai-insight-details-label-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.42);
letter-spacing: 1rpx;
}
.ai-modal-details-label {
padding: 6rpx 0 8rpx 0;
margin-bottom: 4rpx;
}
.ai-modal-details-label-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.42);
letter-spacing: 1rpx;
}
/* 2026-04-22 小程序 Markdown 内联样式:**加粗** / *倾斜* */
.md-seg {
display: inline;
}
.md-bold {
font-weight: 700;
color: rgba(255, 255, 255, 0.98);
}
.md-italic {
font-style: italic;
}
/* 加粗同时倾斜时组合生效class 拼接即可) */
/* CHANGE 2026-04-22 v2 | "查看全部" 按钮居中 */
.ai-insight-more {
margin: 10rpx 24rpx 0 0;
padding: 18rpx 24rpx;
text-align: center;
color: rgba(255, 255, 255, 0.88);
font-size: 26rpx;
background: rgba(255, 255, 255, 0.06);
border-radius: 12rpx;
}
.ai-insight-more-hover {
background: rgba(255, 255, 255, 0.12);
}
.ai-insight-more-text {
letter-spacing: 1rpx;
}
/* CHANGE 2026-04-22 | AI 全部洞察弹窗:覆盖除底部 tab 外整个页面 */
.ai-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 110rpx; /* 避让自定义 tabBar 约 110rpx */
bottom: calc(110rpx + env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.55);
z-index: 9998;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
/* 2026-04-22 v5fixed top+bottom 固定高scroll-view 在 flex max-height 里渲染溢出,放弃 auto 高度) */
.ai-modal {
position: fixed;
left: 24rpx;
right: 24rpx;
top: 40rpx;
bottom: calc(150rpx + env(safe-area-inset-bottom)); /* 110rpx tab + 40rpx 留白 */
background: #2e2e2e;
border-radius: 24rpx;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.45);
z-index: 9999;
}
.ai-modal-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx 20rpx 28rpx;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.08);
}
.ai-modal-title-wrap {
display: flex;
align-items: center;
gap: 14rpx;
flex: 1;
min-width: 0;
}
.ai-modal-title {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-modal-close {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-left: 16rpx;
}
.ai-modal-close-hover {
background: rgba(255, 255, 255, 0.18);
}
.ai-modal-close-icon {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
line-height: 28rpx;
}
.ai-modal-body {
/* 2026-04-22 v5modal 固定高度后 flex:1 1 0 分配剩余空间给 scroll-view */
flex: 1 1 0;
min-height: 0;
padding: 20rpx 28rpx 12rpx 28rpx;
box-sizing: border-box;
}
.ai-modal-item {
padding: 22rpx 0 22rpx 0;
border-bottom: 2rpx dashed rgba(255, 255, 255, 0.1);
}
.ai-modal-item:last-of-type {
border-bottom: none;
}
.ai-modal-item-title {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 10rpx;
}
.ai-modal-item-seq {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 22rpx;
font-weight: 600;
}
.ai-modal-item-name {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
flex: 1;
}
.ai-modal-item-body {
display: block;
font-size: 26rpx;
line-height: 40rpx;
color: rgba(255, 255, 255, 0.78);
text-indent: 48rpx; /* 首行缩进,和 seq 徽章对齐阅读感 */
}
.ai-modal-footer-space {
height: 24rpx;
}
/* 2026-04-22 v3 | 弹窗底部整块作为关闭按钮:固定高度 100rpx贯通整宽 */
.ai-modal-footer {
flex-shrink: 0;
height: 100rpx;
line-height: 100rpx;
text-align: center;
font-size: 30rpx;
font-weight: 500;
color: #fff;
background: linear-gradient(135deg, #667eea, #764ba2);
letter-spacing: 8rpx;
}
.ai-modal-footer-hover {
opacity: 0.82;
}
/* ===== 通用表格边框容器 ===== */
.table-bordered {
border: 2rpx solid #e7e7e7;

View File

@@ -198,6 +198,12 @@ Page({
/** 最后一次发送的用户消息内容(用于重试) */
_lastUserContent: '',
/** SSE 断线重试次数 */
_sseRetryCount: 0,
/** SSE 最大自动重试次数 */
_SSE_MAX_RETRY: 2,
onShow() {
// 权限守卫:检查登录状态、账号禁用、角色权限
checkPageAccess('pages/chat/chat')
@@ -227,10 +233,27 @@ Page({
this.loadMessagesByContext('coach', options.coachId)
} else if (options?.sourcePage) {
// 看板类入口:保存来源页面和筛选参数
const filterKeys = ['timeDimension', 'areaFilter', 'dimension', 'typeFilter', 'projectFilter']
// Phase 2.3:优先解析 options.pageFiltersai-float-button 传入的 JSON 字符串),
// 回退到单独键旧入口兼容timeDimension / areaFilter 等)
const pageFilters: Record<string, string> = {}
for (const key of filterKeys) {
if (options[key]) pageFilters[key] = options[key]
if (options.pageFilters) {
try {
const parsed = JSON.parse(decodeURIComponent(options.pageFilters))
if (parsed && typeof parsed === 'object') {
for (const k of Object.keys(parsed)) {
const v = parsed[k]
if (v != null) pageFilters[k] = String(v)
}
}
} catch {
// JSON 解析失败忽略,回退到单键读取
}
}
if (Object.keys(pageFilters).length === 0) {
const filterKeys = ['timeDimension', 'areaFilter', 'dimension', 'typeFilter', 'projectFilter']
for (const key of filterKeys) {
if (options[key]) pageFilters[key] = options[key]
}
}
this.setData({ sourcePage: options.sourcePage, pageFilters })
this.loadMessagesByContext(options.sourcePage, '')
@@ -418,6 +441,7 @@ Page({
},
// onDone: 流结束,更新消息 ID 和时间
(messageId: number, createdAt: string) => {
this._sseRetryCount = 0
this.setData({
[`messages[${aiIndex}].id`]: String(messageId),
[`messages[${aiIndex}].timestamp`]: createdAt,
@@ -477,8 +501,20 @@ Page({
}
},
fail: () => {
// 网络错误或连接中断
if (this.data.isStreaming) {
// 网络错误或连接中断:无内容时指数退避重连
this._sseTask = null
if (!this.data.isStreaming) return
if (fullContent === '' && this._sseRetryCount < this._SSE_MAX_RETRY) {
this._sseRetryCount++
const delay = (2 ** this._sseRetryCount) * 1000
wx.showToast({ title: `重连中 ${this._sseRetryCount}/${this._SSE_MAX_RETRY}...`, icon: 'loading', duration: delay })
this.setData({
messages: this.data.messages.slice(0, aiIndex),
isStreaming: false,
streamingContent: '',
})
setTimeout(() => { this.triggerAIReply(chatId, content) }, delay)
} else {
const errorContent = fullContent || '连接中断,请重试'
this.setData({
[`messages[${aiIndex}].content`]: errorContent,
@@ -487,7 +523,6 @@ Page({
})
wx.showToast({ title: '连接中断', icon: 'none', duration: 3000 })
}
this._sseTask = null
},
} as WechatMiniprogram.RequestOption)
@@ -509,4 +544,19 @@ Page({
}, 50)
}, 50)
},
/** 点击引用卡片跳转到对应详情页Phase 2.1 */
onRefCardTap(e: WechatMiniprogram.BaseEvent & { currentTarget: { dataset: { link?: string } } }) {
const link = e.currentTarget?.dataset?.link
if (!link || typeof link !== 'string') {
return
}
wx.navigateTo({
url: link,
fail: (err) => {
console.error('跳转引用详情失败', err)
wx.showToast({ title: '跳转失败', icon: 'none' })
},
})
},
})

View File

@@ -92,13 +92,18 @@
<text class="bubble-text">{{item.content}}</text>
</view>
<!-- AI 侧引用卡片(后端 referenceCard 附加在 assistant 回复中)-->
<view class="inline-ref-card inline-ref-card--assistant" wx:if="{{item.referenceCard}}">
<view
class="inline-ref-card inline-ref-card--assistant {{item.referenceCard.link ? 'inline-ref-card--link' : ''}}"
wx:if="{{item.referenceCard}}"
data-link="{{item.referenceCard.link}}"
bindtap="onRefCardTap"
>
<view class="inline-ref-header">
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : '📋 记录'}}</text>
<text class="inline-ref-type">{{item.referenceCard.type === 'customer' ? '👤 客户' : item.referenceCard.type === 'assistant' ? '🧑\u200d🏫 助教' : item.referenceCard.type === 'task' ? '📋 任务' : '📋 记录'}}</text>
<text class="inline-ref-title">{{fmt.safe(item.referenceCard.title)}}</text>
</view>
<text class="inline-ref-summary">{{fmt.safe(item.referenceCard.summary)}}</text>
<view class="inline-ref-data">
<view class="inline-ref-data" wx:if="{{item.referenceCard.dataList.length > 0}}">
<view class="ref-data-item" wx:for="{{item.referenceCard.dataList}}" wx:for-item="entry" wx:key="key">
<text class="ref-data-key">{{fmt.safe(entry.key)}}</text>
<text class="ref-data-value">{{fmt.safe(entry.value)}}</text>

View File

@@ -4,7 +4,7 @@
| 2026-03-23 | 角色路由+页面权限守卫 | onShow 添加 checkPageAccess 权限守卫 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchCustomerDetail } from '../../services/api'
import { fetchCustomerDetail, fetchAICache } from '../../services/api'
interface ConsumptionRecord {
id: string
@@ -132,6 +132,7 @@ Page({
}
}
this.setData({ pageState: 'normal' })
if (id) this._loadAIInsight(id)
} catch (e) {
console.error('[customer-detail] loadDetail 失败:', e)
this.setData({ pageState: 'error' })
@@ -140,6 +141,23 @@ Page({
}
},
async _loadAIInsight(memberId: string) {
const cache = await fetchAICache('app7_customer_analysis', memberId)
if (!cache?.result_json) return
const rj = cache.result_json as any
const COLORS = ['blue', 'indigo', 'purple', 'red', 'orange', 'yellow'] as const
const strategies = Array.isArray(rj.strategies)
? rj.strategies.map((s: any, i: number) => ({
color: COLORS[i % COLORS.length],
text: s.title || s.text || '',
}))
: []
this.setData({
'aiInsight.summary': rj.summary || '',
'aiInsight.strategies': strategies,
})
},
onRetry() {
const id = this.data.detail?.id || ''
this.loadDetail(id)

View File

@@ -5,6 +5,8 @@
*/
import { checkPageAccess } from '../../utils/auth-guard'
import { fetchCustomerConsumptionRecords } from '../../services/api'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
Page({
data: {
@@ -38,11 +40,12 @@ Page({
monthLoading: false,
},
onLoad(options) {
async onLoad(options) {
const id = options?.customerId || options?.id || ''
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | 默认当前年月走业务时钟sandbox 模式按 sandbox_date 显示
const clock = await getBusinessClock()
const currentYear = clock.business_year
const currentMonth = clock.business_month
this.setData({
customerId: id,
currentYear,

View File

@@ -5,6 +5,8 @@
| 2026-03-27 | 任务A 前端改造 | 修复数据转换duration/income/timeRange/table/recordType去掉 loadCustomerInfo 改从 records 响应取客户信息,新增 monthIncome 展示 |
*/
import { checkPageAccess } from '../../utils/auth-guard'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
// CHANGE 2026-03-27 | 任务A A5: 去掉 fetchCustomerDetail客户信息从 fetchCustomerRecords 响应中获取
import { fetchCustomerRecords } from '../../services/api'
import { formatCount } from '../../utils/money'
@@ -86,12 +88,12 @@ Page({
monthLoading: false,
},
onLoad(options) {
async onLoad(options) {
const id = options?.customerId || options?.id || ''
// 默认当前年月
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | 默认当前年月走业务时钟sandbox 模式按 sandbox_date 显示
const clock = await getBusinessClock()
const currentYear = clock.business_year
const currentMonth = clock.business_month
this.setData({
customerId: id,
currentYear,

View File

@@ -11,6 +11,8 @@ import { nameToAvatarColor } from '../../utils/avatar-color'
import { formatMoney, formatCount } from '../../utils/money'
import { formatHours } from '../../utils/time'
import { API_BASE } from '../../utils/config'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
/** 中文课程类型 → 英文 CSS keyWXSS 不支持中文类名) */
const COURSE_TAG_MAP: Record<string, string> = {
@@ -58,7 +60,7 @@ Page({
coachRole: '',
storeName: '',
/** 月份切换 */
/** 月份切换onLoad 中改写为业务时钟当前年月) */
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthLabel: '',
@@ -83,12 +85,13 @@ Page({
hasMore: false,
},
onLoad() {
const now = new Date()
async onLoad() {
// CHANGE 2026-05-02 | 用业务时钟初始化年月sandbox 模式按 sandbox_date 显示
const clock = await getBusinessClock()
this.setData({
currentYear: now.getFullYear(),
currentMonth: now.getMonth() + 1,
monthLabel: `${now.getFullYear()}${now.getMonth() + 1}`,
currentYear: clock.business_year,
currentMonth: clock.business_month,
monthLabel: `${clock.business_year}${clock.business_month}`,
})
this.loadBanner()
this.loadData()
@@ -140,11 +143,13 @@ Page({
wx.showLoading({ title: '加载中...', mask: true })
// 预估规则:当月且当前日期 ≤ 5号全小程序统一
const now = new Date()
// CHANGE 2026-05-02 | 用业务时钟sandbox 模式按 sandbox_date 判断
const clock = await getBusinessClock()
const { currentYear, currentMonth } = this.data
const isCurrentMonth = currentYear === now.getFullYear()
&& currentMonth === now.getMonth() + 1
&& now.getDate() <= 5
const businessDay = parseInt(clock.business_date.slice(8, 10), 10) || 1
const isCurrentMonth = currentYear === clock.business_year
&& currentMonth === clock.business_month
&& businessDay <= 5
try {
const res = await fetchPerformanceRecords({
@@ -243,7 +248,7 @@ Page({
},
/** 切换月份 */
switchMonth(e: WechatMiniprogram.TouchEvent) {
async switchMonth(e: WechatMiniprogram.TouchEvent) {
const direction = e.currentTarget.dataset.direction as 'prev' | 'next'
let { currentYear, currentMonth } = this.data
@@ -255,11 +260,13 @@ Page({
if (currentMonth > 12) { currentMonth = 1; currentYear++ }
}
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | 用业务时钟sandbox 模式下不允许"翻到 sandbox_date 之后"
const clock = await getBusinessClock()
const nowYear = clock.business_year
const nowMonth = clock.business_month
const businessDay = parseInt(clock.business_date.slice(8, 10), 10) || 1
const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth)
const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && now.getDate() <= 5
const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && businessDay <= 5
// 月份切换重置分页到第 1 页
this.setData({

View File

@@ -10,6 +10,8 @@ import { fetchMe, fetchPerformanceOverview } from '../../services/api'
import { nameToAvatarColor } from '../../utils/avatar-color'
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
/** 中文课程类型 → 英文 CSS keyWXSS 不支持中文类名) */
const COURSE_TAG_MAP: Record<string, string> = {
@@ -118,15 +120,16 @@ Page({
this.setData({ pageState: 'loading' })
wx.showLoading({ title: '加载中...', mask: true })
// G2当月预估判断
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | G2 当月预估判断改用业务时钟sandbox 模式按 sandbox_date 判断)
const clock = await getBusinessClock()
const nowYear = clock.business_year
const nowMonth = clock.business_month
const businessDay = parseInt(clock.business_date.slice(8, 10), 10) || 1
// TODO: 联调时从接口参数或页面参数获取 year/month
const year = nowYear
const month = nowMonth
// CHANGE 2026-03-24 | 预估规则:当月且当前日期 ≤ 5号才显示"预估"
const isCurrentMonth = year === nowYear && month === nowMonth && now.getDate() <= 5
const isCurrentMonth = year === nowYear && month === nowMonth && businessDay <= 5
try {
// 并行请求用户信息和绩效概览

View File

@@ -20,6 +20,8 @@ import { fetchTasks, fetchMe, pinTask, unpinTask, abandonTask, restoreTask, crea
import { formatMoney } from '../../utils/money'
import { formatDeadline } from '../../utils/time'
import { formatStorageLevel } from '../../utils/storage-level'
// CHANGE 2026-05-02 | 业务时钟sandbox 模式下用 business_year/month 替代 new Date()
import { getBusinessClock } from '../../utils/runtime-clock'
// CHANGE 2026-03-27 | 头像:需要 API_BASE 构建头像完整 URL
import { API_BASE } from '../../utils/config'
import {
@@ -386,9 +388,11 @@ Page({
}
// G2: 当月预估判断
const now = new Date()
const nowYear = now.getFullYear()
const nowMonth = now.getMonth() + 1
// CHANGE 2026-05-02 | 用业务时钟sandbox 模式按 sandbox_date 判断
const clock = await getBusinessClock()
const nowYear = clock.business_year
const nowMonth = clock.business_month
const businessDay = parseInt(clock.business_date.slice(8, 10), 10) || 1
const incomeMonth = perfData.incomeMonth
let dataYear = nowYear
let dataMonth = nowMonth
@@ -397,7 +401,7 @@ Page({
if (parts) dataMonth = parseInt(parts[1], 10)
}
// CHANGE 2026-03-24 | 预估规则:当月且当前日期 ≤ 5号才显示"预估"(全小程序统一)
const isCurrentMonth = dataYear === nowYear && dataMonth === nowMonth && now.getDate() <= 5
const isCurrentMonth = dataYear === nowYear && dataMonth === nowMonth && businessDay <= 5
this.setData({
pageState: totalCount > 0 ? 'normal' : 'empty',

View File

@@ -35,6 +35,31 @@ export async function fetchMe(): Promise<ApiUserInfo> {
return request({ url: '/api/xcx/me', method: 'GET', needAuth: true })
}
// ============================================
// 业务时钟(沙箱支持)
// ============================================
export interface RuntimeClock {
mode: 'live' | 'sandbox'
business_date: string // YYYY-MM-DD
business_year: number
business_month: number
business_year_month: string // YYYY-MM
business_now: string
is_sandbox: boolean
sandbox_date: string | null
sandbox_instance_id: string | null
}
/**
* 获取当前门店的业务时钟live 真实日sandbox 模拟日)。
* 沙箱模式下,小程序所有依赖"当前年月"的请求都应使用此结果,
* 避免直接 ``new Date()`` 导致与后端 sandbox_date 不一致。
*/
export async function fetchRuntimeClock(): Promise<RuntimeClock> {
return request({ url: '/api/xcx/runtime/clock', method: 'GET', needAuth: true })
}
// ============================================
// 任务模块
// ============================================
@@ -413,6 +438,26 @@ export async function sendChatMessage(chatId: string, content: string): Promise<
// 配置模块
// ============================================
/** AI 缓存查询Phase 2.5 */
export async function fetchAICache(cacheType: string, targetId: string): Promise<{
result_json: Record<string, any> | null;
score: number | null;
} | null> {
try {
const data = await request({
url: `/api/ai/cache/${cacheType}`,
method: 'GET',
data: { target_id: targetId },
needAuth: true,
})
if (!data) return null
const d = data as any
return { result_json: d.result_json ?? null, score: d.score ?? null }
} catch {
return null
}
}
/** 项目类型筛选器列表CONFIG-1 */
// CHANGE 2026-03-20 | R3 修复value 改为数据库 category_codefallback 与后端一致
export async function fetchSkillTypes(): Promise<Array<{ value: string; text: string; icon?: string }>> {

View File

@@ -0,0 +1,83 @@
// 业务时钟缓存
//
// sandbox 模式下,小程序的 performance / task-list / customer-records 等页面
// 需要按"业务日"而不是"真实今天"构造请求参数。
//
// 用法:
// import { getBusinessClock, getBusinessYearMonth } from '../../utils/runtime-clock'
// const clock = await getBusinessClock()
// wx.request({ url: ..., data: { year: clock.business_year, month: clock.business_month } })
//
// 缓存策略:
// - 单例 in-memory cache最多 60 秒;过期后自动重新拉取。
// - 切换沙箱后,建议页面调用 `clearBusinessClockCache()` 主动失效。
import { fetchRuntimeClock, type RuntimeClock } from '../services/api'
const TTL_MS = 60_000 // 60 秒缓存,足以覆盖一次页面进入
let cached: { value: RuntimeClock; ts: number } | null = null
let inflight: Promise<RuntimeClock> | null = null
/** 主动清空业务时钟缓存(沙箱切换、登出后调用)。 */
export function clearBusinessClockCache(): void {
cached = null
inflight = null
}
/** 拉取业务时钟(可能命中缓存)。失败时降级为本地"今天"。 */
export async function getBusinessClock(force = false): Promise<RuntimeClock> {
const now = Date.now()
if (!force && cached && now - cached.ts < TTL_MS) {
return cached.value
}
if (inflight) {
return inflight
}
inflight = (async () => {
try {
const clock = await fetchRuntimeClock()
cached = { value: clock, ts: Date.now() }
return clock
} catch (err) {
console.warn('[runtime-clock] 拉取业务时钟失败,降级为本地时间', err)
return localFallback()
} finally {
inflight = null
}
})()
return inflight
}
/** 便捷方法:返回业务年月 (YYYY-MM)。 */
export async function getBusinessYearMonth(): Promise<{ year: number; month: number; label: string }> {
const clock = await getBusinessClock()
return {
year: clock.business_year,
month: clock.business_month,
label: `${clock.business_year}${clock.business_month}`,
}
}
/** 便捷方法:返回业务日 (YYYY-MM-DD)。 */
export async function getBusinessDate(): Promise<string> {
return (await getBusinessClock()).business_date
}
function localFallback(): RuntimeClock {
const d = new Date()
const year = d.getFullYear()
const month = d.getMonth() + 1
const ymd = `${year}-${String(month).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
return {
mode: 'live',
business_date: ymd,
business_year: year,
business_month: month,
business_year_month: `${year}-${String(month).padStart(2, '0')}`,
business_now: d.toISOString(),
is_sandbox: false,
sandbox_date: null,
sandbox_instance_id: null,
}
}