Enhance Beryl Agent detail and report views
This commit is contained in:
@@ -5,11 +5,12 @@ import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { AdsMonitor } from "./monitor.js";
|
||||
import {
|
||||
MessageInputSchema,
|
||||
ModelConnectionInputSchema,
|
||||
} from "@agent-studio/shared";
|
||||
import { SessionRegistry, SkillCatalog } from "@agent-studio/harness";
|
||||
import { SessionRegistry, SkillCatalog, validatedPpt } from "@agent-studio/harness";
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../",
|
||||
@@ -28,16 +29,32 @@ export async function buildApp() {
|
||||
},
|
||||
});
|
||||
const registry = new SessionRegistry(root);
|
||||
const monitor = new AdsMonitor(path.join(root,"workspace/monitoring"));
|
||||
await monitor.init();
|
||||
registry.monitorSnapshot = () => ({ ...monitor.state, busy: monitor.busy });
|
||||
registry.monitoringBusy = () => monitor.busy;
|
||||
monitor.canCheck = () => !registry.hasActiveRuns();
|
||||
app.addHook("onClose",async()=>monitor.close());
|
||||
app.get("/api/monitoring",async()=>({ ...monitor.state,busy:monitor.busy }));
|
||||
app.post("/api/monitoring/check",async()=>monitor.check());
|
||||
app.put("/api/monitoring/settings",async req=>monitor.configure(req.body));
|
||||
app.post("/api/monitoring/handled",async req=>{
|
||||
const key=(req.body as {key?:unknown})?.key;
|
||||
if(typeof key!=="string") throw new Error("INVALID_ALERT_KEY");
|
||||
return monitor.handle(key);
|
||||
});
|
||||
await registry.restore();
|
||||
const skills = new SkillCatalog(root);
|
||||
await skills.reload();
|
||||
app.decorate("registry", registry);
|
||||
app.get("/health", async (req) => ({ ok: true, requestId: req.id }));
|
||||
app.get("/api/skills", async (req) => ({
|
||||
skills: skills.items,
|
||||
skills: await skills.reload(),
|
||||
requestId: req.id,
|
||||
}));
|
||||
app.post("/api/sessions", async (req, reply) => {
|
||||
const s = await registry.create();
|
||||
const resumeId = (req.body as { resumeSessionId?: unknown } | undefined)?.resumeSessionId;
|
||||
const s = typeof resumeId === "string" ? registry.get(resumeId) ?? await registry.create() : await registry.create();
|
||||
return reply.code(201).send({ sessionId: s.sessionId, requestId: req.id });
|
||||
});
|
||||
app.delete("/api/sessions/:id", async (req, reply) => {
|
||||
@@ -127,6 +144,12 @@ export async function buildApp() {
|
||||
app.get("/api/sessions/:id/reports/:kind", async (req, reply) => {
|
||||
const params = req.params as { id: string; kind: string };
|
||||
const session = registry.require(params.id);
|
||||
if (params.kind === "document") {
|
||||
const body = await readFile(session.workspace.resolve("report.md"));
|
||||
return reply.header("cache-control", "no-store")
|
||||
.header("content-disposition", 'attachment; filename="report.md"')
|
||||
.type("text/markdown; charset=utf-8").send(body);
|
||||
}
|
||||
if (params.kind !== "daily" && params.kind !== "weekly")
|
||||
throw Object.assign(new Error("REPORT_KIND_NOT_FOUND"), { statusCode: 404 });
|
||||
const file = session.workspace.resolve(`swads-${params.kind}-report.png`);
|
||||
@@ -182,6 +205,13 @@ export async function buildApp() {
|
||||
const runId = await registry.run(s, promptText, images);
|
||||
return reply.code(202).send({ runId, requestId: req.id });
|
||||
});
|
||||
app.get("/api/sessions/:id/presentations/:artifact", async (req, reply) => {
|
||||
const { id, artifact } = req.params as { id: string; artifact: string };
|
||||
const bytes = await validatedPpt(registry.require(id).workspace, artifact);
|
||||
return reply.header("cache-control", "no-store")
|
||||
.header("content-disposition", 'attachment; filename="beryl-report.pptx"')
|
||||
.type("application/vnd.openxmlformats-officedocument.presentationml.presentation").send(bytes);
|
||||
});
|
||||
app.post("/api/sessions/:id/cancel", async (req) => {
|
||||
await registry.cancel((req.params as { id: string }).id);
|
||||
return { ok: true, requestId: req.id };
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const fatigueSettingsSchema = z.object({
|
||||
fatigueCtrDrop: z.number().min(5).max(90).default(25),
|
||||
fatigueViewDrop: z.number().min(5).max(90).default(20),
|
||||
fatigueMinClicks: z.number().int().min(10).max(10000).default(30),
|
||||
});
|
||||
export type FatigueSettings = z.infer<typeof fatigueSettingsSchema> & { minImpressions: number };
|
||||
type Point = { date: string; impressions: number; clicks: number; plays: number | null; views: number | null };
|
||||
export type Creative = { id: string; campaignId: string; kind: string; variant: string; points: Point[] };
|
||||
export function decodeCreatives(raw: Record<string, unknown>, accountId: string, tenant: string, from: string, to: string) {
|
||||
const data = z.object({ columns:z.array(z.object({name:z.string()})),rows:z.array(z.array(z.unknown())),account_context:z.object({external_account_id:z.string(),tenant_id:z.string()}),query_meta:z.object({truncated:z.boolean(),attribution_resolutions:z.array(z.object({status:z.string(),freshness:z.string().nullable()})).optional()}) }).parse(raw);
|
||||
if(data.account_context.external_account_id!==accountId || data.account_context.tenant_id!==tenant) throw new Error("素材响应账户不符");
|
||||
if(data.query_meta.truncated || data.rows.length>=5000) throw new Error("素材结果被截断,未完成全量疲劳检查");
|
||||
const map=new Map<string,Creative>(); const seen=new Set<string>(); let excluded=0;
|
||||
for(const row of data.rows) {
|
||||
const o=Object.fromEntries(data.columns.map((c,i)=>[c.name,row[i]]));
|
||||
if(!["video","item"].includes(String(o.creative_identity_kind))) { excluded++; continue; }
|
||||
// Unlinked library videos are not Campaign delivery records; report them as excluded.
|
||||
if(o.external_campaign_id==="" || o.external_campaign_id===null) {excluded++;continue;}
|
||||
const identity=z.object({external_creative_id:z.string().min(1),external_campaign_id:z.string().min(1),creative_variant:z.string().nullish()}).parse(o);
|
||||
const id=identity.external_creative_id,campaignId=identity.external_campaign_id;
|
||||
const kind=String(o.creative_identity_kind),variant=identity.creative_variant??"";
|
||||
const date=z.string().regex(/^\d{4}-\d{2}-\d{2}$/).parse(o.date);
|
||||
if(date<from || date>to) throw new Error("素材日期超出查询范围");
|
||||
const key=JSON.stringify([campaignId,id,kind,variant]), daily=JSON.stringify([key,date]);
|
||||
if(seen.has(daily)) throw new Error("素材日指标重复,未进行汇总"); seen.add(daily);
|
||||
const number=(name:string)=>z.number().finite().nonnegative().parse(o[name]);
|
||||
const optional=(name:string)=>o[name]==null?null:number(name);
|
||||
const p={date,impressions:number("product_impressions"),clicks:number("product_clicks"),plays:optional("video_play_actions"),views:optional("video_watched_6s")};
|
||||
if(p.clicks>p.impressions) throw new Error("素材点击数超过曝光,口径待核实");
|
||||
const c=map.get(key)??{id,campaignId,kind,variant,points:[]}; c.points.push(p); map.set(key,c);
|
||||
}
|
||||
return {map,excluded,resolutions:data.query_meta.attribution_resolutions??[]};
|
||||
}
|
||||
function totals(points:Point[]) {
|
||||
const impressions=points.reduce((n,p)=>n+p.impressions,0),clicks=points.reduce((n,p)=>n+p.clicks,0);
|
||||
const viewing=points.length>0&&points.every(p=>p.plays!==null&&p.plays>0&&p.views!==null&&p.views<=p.plays);
|
||||
const plays=viewing?points.reduce((n,p)=>n+p.plays!,0):0;
|
||||
return {impressions,clicks,days:points.filter(p=>p.impressions>0).length,ctr:impressions>0?clicks/impressions:null,viewRate:plays>0?points.reduce((n,p)=>n+p.views!,0)/plays:null};
|
||||
}
|
||||
export function evaluateFatigue(c:Creative, split:string, s:FatigueSettings, fresh:boolean, blocked?:string) {
|
||||
const recent=totals(c.points.filter(p=>p.date>=split)),baseline=totals(c.points.filter(p=>p.date<split));
|
||||
const ctrDrop=baseline.ctr&&recent.ctr!==null?(1-recent.ctr/baseline.ctr)*100:null;
|
||||
const viewDrop=baseline.viewRate&&recent.viewRate!==null?(1-recent.viewRate/baseline.viewRate)*100:null;
|
||||
const suggestions=["先核对受众、预算、目标、价格、库存及归因变化;本规则未自动排除这些原因", "人工测试新开头或新卖点,在相近投放条件下对照;24小时后复查,不自动暂停或调整预算"];
|
||||
const out=(state:string,reason:string)=>({id:c.id,campaignId:c.campaignId,kind:c.kind,variant:c.variant,recent,baseline,ctrDrop,viewDrop,state,reason,suggestions,frequencyAvailable:false});
|
||||
if(!fresh || blocked) return out("data_issue",blocked??"素材数据同步过期或不可用,未判定疲劳");
|
||||
if(recent.days<3||baseline.days<3||recent.impressions<s.minImpressions||baseline.impressions<s.minImpressions||baseline.clicks<s.fatigueMinClicks) return out("observe","历史覆盖或样本不足:近期需3个有曝光日,基线至少3日,两个窗口均需达到曝光门槛且基线点击达标");
|
||||
if(ctrDrop===null||ctrDrop<s.fatigueCtrDrop) return out("normal","未触发点击率衰退规则,不代表素材已被全面验收");
|
||||
if(viewDrop!==null&&viewDrop>=s.fatigueViewDrop) return out("suspected_fatigue","商品点击率与6秒观看率同时衰退:疑似素材疲劳,需人工排除其他原因");
|
||||
return out("creative_decline",viewDrop===null?"商品点击率明显下降;缺少有效观看数据及准确频次,疲劳证据不足":"商品点击率明显下降,但观看衰退未达门槛,暂不判定疲劳");
|
||||
}
|
||||
export type FatigueResult=ReturnType<typeof evaluateFatigue> & {key:string;handled:boolean;period:string;account:{id:string;tenant:string;name:string;timezone:string;currency:string}};
|
||||
@@ -13,4 +13,4 @@ try {
|
||||
}
|
||||
|
||||
const app = await buildApp();
|
||||
await app.listen({ host: "0.0.0.0", port: Number(process.env.PORT) || 3001 });
|
||||
await app.listen({ host: process.env.HOST ?? "0.0.0.0", port: Number(process.env.PORT) || 3001 });
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { readSwadsMonitoring } from "@agent-studio/harness";
|
||||
import { decodeCreatives, evaluateFatigue, fatigueSettingsSchema, type FatigueResult } from "./fatigue.js";
|
||||
export const settingsSchema = z.object({ enabled: z.boolean(), minSpend: z.number().positive().max(1000000), minOrders: z.number().int().min(1).max(10000), minImpressions: z.number().int().min(100), staleHours: z.number().min(1).max(72) }).extend(fatigueSettingsSchema.shape);
|
||||
export type Settings = z.infer<typeof settingsSchema>;
|
||||
export const defaults: Settings = settingsSchema.parse({ enabled: false, minSpend: 100, minOrders: 5, minImpressions: 1000, staleHours: 12 });
|
||||
type Metrics = { spend: number; revenue: number; orders: number; impressions: number; clicks: number };
|
||||
export type Campaign = { id: string; name: string; target: number | null; active: boolean; synced: string | null; started: string | null; pending: boolean; budget: number | null; metrics?: Metrics; previous?: Metrics };
|
||||
export function evaluate(c: Campaign, s: Settings, now: number, fresh: boolean) {
|
||||
const m = c.metrics, p = c.previous;
|
||||
const roi = m && m.spend > 0 ? m.revenue / m.spend : null;
|
||||
const cpa = m && m.orders > 0 ? m.spend / m.orders : null;
|
||||
const ctr = m && m.impressions > 0 ? m.clicks / m.impressions : null;
|
||||
const out = (state: string, reason: string, suggestions: string[]) => ({ ...c, roi, cpa, ctr, state, reason, suggestions });
|
||||
if (!c.active) return out("inactive", "Campaign 未启用", ["不建议扩量;如需重启,先人工检查配置"]);
|
||||
if (!fresh || !c.synced || !Number.isFinite(Date.parse(c.synced)) || now - Date.parse(c.synced) > s.staleHours * 3600000 || c.pending) return out("data_issue", "数据/配置同步过期、不可用或有未发布修改", ["先核对平台同步及待发布配置,不调整投放"]);
|
||||
if (!c.target || c.target <= 0) return out("target_missing", "未读取到 Campaign 目标 ROI", ["请在平台核实目标;不会默认使用 2.5"]);
|
||||
if (!m) return out("data_issue", "Campaign 无指标行,不能视作零消耗", ["检查报表覆盖与同步"]);
|
||||
if (m.spend < s.minSpend || !c.started || !Number.isFinite(Date.parse(c.started)) || now - Date.parse(c.started) < 72 * 3600000) return out("observe", "消耗不足或新 Campaign,先观察", ["补足样本;24小时后复查"]);
|
||||
const previousRoi = p && p.spend >= s.minSpend ? p.revenue / p.spend : null;
|
||||
if (m.orders === 0) return out("no_orders", "达到消耗门槛但无订单", [p && p.spend >= s.minSpend && p.orders === 0 ? "连续两个完整业务日无订单:核查归因、库存后,人工评估暂停" : "核查归因、库存和商品页;暂不扩量", "24小时后复查"]);
|
||||
if (m.orders < s.minOrders) return out("observe", "订单样本不足", ["继续观察,暂不调整预算或目标"]);
|
||||
const ideas: string[] = [];
|
||||
if (ctr !== null && p && p.impressions >= s.minImpressions && m.impressions >= s.minImpressions && p.clicks > 0 && ctr < (p.clicks / p.impressions) * 0.7) ideas.push("商品 CTR 较前日下降超30%:检查素材层级,测试新开头/卖点;不能仅凭 Campaign 指标断定素材疲劳");
|
||||
if (p && p.orders >= s.minOrders && p.spend > 0 && cpa! > (p.spend / p.orders) * 1.3) ideas.push("CPA 较前日上升超30%:检查商品页、转化追踪及素材质量");
|
||||
if (roi! >= c.target * 1.2 && previousRoi !== null && previousRoi >= c.target && p!.orders >= s.minOrders) return out("potential", "连续两日达到目标,最新 ROI 高于目标20%以上", ["高潜候选:人工评估预算增加10%,确认库存和毛利后执行;48小时复查", ...ideas]);
|
||||
if (roi! < c.target * 0.8) return out("under_target", "ROI 低于本 Campaign 目标的80%(不等同亏损)", [previousRoi !== null && previousRoi < c.target * 0.8 ? "连续两日不达标:人工评估降低预算10%;严重时核查归因与毛利后评估暂停" : "暂不扩量,等待下一完整业务日确认", "复核目标 ROI 是否与毛利和历史表现匹配,不为达标而机械降低目标", ...ideas, "24小时后复查"]);
|
||||
return out(ideas.length ? "creative_review" : "normal", ideas.length ? "效率趋势异常,需进一步诊断" : "样本达门槛,未触发异常规则", ideas.length ? ideas : ["保持配置,按日观察"]);
|
||||
}
|
||||
type Account = { id: string; name: string; tenant: string; timezone: string; currency: string };
|
||||
type Result = ReturnType<typeof evaluate> & { key: string; handled: boolean; account?: Account; period?: string };
|
||||
type AccountResult = Account & { period?: string; error?: string; fatigueError?:string; fatigueCount?:number; fatigueExcluded?:number; count: number; excluded: number; checkedAt?: string };
|
||||
type State = { settings: Settings; account?: Account; accounts?: AccountResult[]; lastSuccess?: string; lastCheck?: string; period?: string; error?: string; rows: Result[]; fatigueRows?:FatigueResult[]; handled: string[]; newAlerts: number };
|
||||
const accountSchema = z.object({ external_account_id: z.string(), account_name: z.string(), tenant_id: z.string(), platform: z.string(), reporting_timezone: z.string(), reporting_utc_offset_minutes: z.number(), account_currency: z.string() });
|
||||
const campaignSchema = z.object({ external_campaign_id: z.string(), campaign_name: z.string(), external_account_id: z.string(), subject_type: z.string(), operation_status: z.string().nullable(), last_synced_at: z.string().nullable(), pending_workspace_changes: z.object({ total: z.number() }).optional(), commerce_summary: z.object({ target_roi: z.number().nullable().optional(), daily_budget: z.number().nullable().optional(), schedule_start_at: z.string().nullable().optional() }).nullable().optional() });
|
||||
export function decodeMetrics(raw: Record<string, unknown>) {
|
||||
const data = z.object({ columns: z.array(z.object({ name: z.string() })), rows: z.array(z.array(z.unknown())), query_meta: z.object({ truncated: z.boolean(), attribution_resolutions: z.array(z.object({ freshness: z.string().nullable(), status: z.string() })).optional() }) }).parse(raw);
|
||||
if (data.query_meta.truncated || data.rows.length >= 5000) throw new Error("指标结果被截断,不能完成全量检查");
|
||||
const map = new Map<string, Metrics>();
|
||||
for (const row of data.rows) {
|
||||
const obj = Object.fromEntries(data.columns.map((c,i) => [c.name,row[i]]));
|
||||
const id = z.string().parse(obj.external_campaign_id);
|
||||
if (map.has(id)) throw new Error("出现重复 Campaign 指标行");
|
||||
const number = (key: string) => z.number().finite().nonnegative().parse(obj[key]);
|
||||
map.set(id,{ spend:number("spend"),revenue:number("gross_revenue"),orders:number("orders"),impressions:number("product_impressions"),clicks:number("product_clicks") });
|
||||
}
|
||||
return { map, resolutions: data.query_meta.attribution_resolutions ?? [] };
|
||||
}
|
||||
export class AdsMonitor {
|
||||
state: State = { settings: defaults, rows: [], handled: [], newAlerts: 0 };
|
||||
busy = false;
|
||||
canCheck: () => boolean = () => true;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
constructor(private dir: string, private read = readSwadsMonitoring) {}
|
||||
async init() {
|
||||
await mkdir(this.dir,{ recursive:true, mode:0o700 });
|
||||
try { this.state = JSON.parse(await readFile(path.join(this.dir,"state.json"),"utf8")); this.state.settings = settingsSchema.parse(this.state.settings); }
|
||||
catch(e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; }
|
||||
this.timer = setInterval(() => { if (this.state.settings.enabled) void this.check().catch(()=>{this.state.error="预警保存失败,请检查磁盘";}); },3600000);
|
||||
this.timer.unref();
|
||||
}
|
||||
close() { clearInterval(this.timer); }
|
||||
private async save() { const file = path.join(this.dir,`state-${crypto.randomUUID()}.tmp`); await writeFile(file,JSON.stringify(this.state),{mode:0o600}); await rename(file,path.join(this.dir,"state.json")); }
|
||||
async configure(input: unknown) { if (this.busy) throw new Error("检查中,请稍后修改"); this.state.settings = settingsSchema.parse(input); await this.save(); return this.state; }
|
||||
async handle(key: string) { if(this.busy) throw new Error("检查中,请稍后处理"); const rows=[...this.state.rows,...this.state.fatigueRows??[]]; if(!rows.some(r=>r.key===key)) throw new Error("预警不存在"); this.state.handled = [...new Set([...this.state.handled,key])].slice(-5000); rows.forEach(r=>{r.handled=this.state.handled.includes(r.key);}); await this.save(); return this.state; }
|
||||
async check() {
|
||||
if (this.busy) return this.state;
|
||||
if (!this.canCheck()) { this.state.error="Agent 正在执行任务,为避免账户切换冲突,本次检查延后;请任务结束后重试"; await this.save(); return this.state; }
|
||||
this.busy = true;
|
||||
try {
|
||||
const who = z.object({access:z.object({org_id:z.string(),current_account:accountSchema,accounts:z.array(accountSchema)})}).parse(await this.read("swads_whoami"));
|
||||
const original = who.access.current_account;
|
||||
const visible = who.access.accounts.filter(a=>a.platform==="tiktok");
|
||||
if(new Set(visible.map(a=>`${a.tenant_id}:${a.external_account_id}`)).size!==visible.length) throw new Error("账户列表重复,检查中止");
|
||||
const rows: Result[] = [], accounts: AccountResult[] = [], fatigueRows:FatigueResult[]=[];
|
||||
let restoreError: string | undefined;
|
||||
try {
|
||||
for(const a of visible) {
|
||||
const account: Account = {id:a.external_account_id,name:a.account_name,tenant:a.tenant_id,timezone:a.reporting_timezone,currency:a.account_currency};
|
||||
try {
|
||||
const collected=await this.collect(a,who.access.org_id);
|
||||
rows.push(...collected.rows.map(r=>({...r,account,period:collected.period})));
|
||||
const result:AccountResult={...account,period:collected.period,count:collected.rows.length,excluded:collected.excluded,checkedAt:new Date().toISOString()};
|
||||
try { const fatigue=await this.collectFatigue(a,who.access.org_id,collected.rows); fatigueRows.push(...fatigue.rows); result.fatigueCount=fatigue.rows.length; result.fatigueExcluded=fatigue.excluded; }
|
||||
catch(e) { result.fatigueError=this.describeError(e); }
|
||||
accounts.push(result);
|
||||
} catch(e) { accounts.push({...account,count:0,excluded:0,error:this.describeError(e)}); }
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await this.read("campaign_list",{external_account_id:original.external_account_id,tenant_id:original.tenant_id,page:1,page_size:1});
|
||||
const end=z.object({access:z.object({current_account:accountSchema})}).parse(await this.read("swads_whoami"));
|
||||
if(end.access.current_account.external_account_id!==original.external_account_id || end.access.current_account.tenant_id!==original.tenant_id) restoreError="未能恢复原 SW Ads 当前账户,请在平台核对账户选择";
|
||||
} catch { restoreError="未能恢复原 SW Ads 当前账户,请在平台核对账户选择"; }
|
||||
}
|
||||
const old=new Set([...this.state.rows,...this.state.fatigueRows??[]].map(r=>r.key));
|
||||
const failures=accounts.filter(a=>a.error).length;
|
||||
const error=[restoreError,failures?`${failures} 个账户检查失败,请查看各账户原因`:undefined,accounts.some(a=>a.fatigueError)?"部分账户素材检查失败,不能视为正常":undefined,!visible.length?"没有可访问的 TikTok 账户":undefined].filter(Boolean).join(";")||undefined;
|
||||
this.state={...this.state,account:undefined,period:undefined,accounts,rows,fatigueRows,lastCheck:new Date().toISOString(),lastSuccess:error?this.state.lastSuccess:new Date().toISOString(),error,newAlerts:[...rows,...fatigueRows].filter(r=>!old.has(r.key)&&!["normal","observe","inactive"].includes(r.state)).length};
|
||||
} catch(e) { this.state.error = this.describeError(e); }
|
||||
finally { this.busy=false; await this.save(); }
|
||||
return this.state;
|
||||
}
|
||||
private describeError(e: unknown) { return e instanceof z.ZodError ? `SW Ads 字段不符:${e.issues.map(i=>i.path.join(".")).slice(0,5).join(",")},未执行判断` : e instanceof Error ? e.message.slice(0,200) : "检查失败"; }
|
||||
private async collectFatigue(a:z.infer<typeof accountSchema>,org:string,campaigns:Result[]) {
|
||||
if(!campaigns.length) return {rows:[],excluded:0};
|
||||
const date=(ago:number)=>new Date(Date.now()+a.reporting_utc_offset_minutes*60000-ago*86400000).toISOString().slice(0,10);
|
||||
const from=date(10),split=date(3),to=date(1),period=`${split}~${to} 对比 ${from}~${date(4)}`;
|
||||
const data:ReturnType<typeof decodeCreatives>={map:new Map(),excluded:0,resolutions:[]};
|
||||
// Query each complete day to avoid the API's 5000-row cap across a ten-day window.
|
||||
for(let ago=10;ago>=1;ago--) {
|
||||
const day=date(ago);
|
||||
const part=decodeCreatives(await this.read("metrics_semantic_query",{query:{tenant_id:a.tenant_id,org_id:org,platform:a.platform,account_id:a.external_account_id,query_kind:"creative_breakdown",metrics:["product_impressions","product_clicks","video_play_actions","video_watched_6s"],dimensions:["external_campaign_id","external_creative_id","creative_identity_kind","creative_variant","date"],date_from:day,date_to:day,reporting_timezone:a.reporting_timezone,reporting_utc_offset_minutes:a.reporting_utc_offset_minutes,limit:5000}}),a.external_account_id,a.tenant_id,day,day);
|
||||
if(!part.resolutions.length) throw new Error("素材日数据缺少同步依据,不能完成检查");
|
||||
data.resolutions.push(...part.resolutions);data.excluded+=part.excluded;
|
||||
for(const [key,c] of part.map) { const previous=data.map.get(key); if(previous) previous.points.push(...c.points); else data.map.set(key,c); }
|
||||
}
|
||||
const now=Date.now(),fresh=data.resolutions.length>0&&data.resolutions.every(r=>r.status==="available"&&r.freshness&&Number.isFinite(Date.parse(r.freshness))&&now-Date.parse(r.freshness)>=-300000&&now-Date.parse(r.freshness)<=this.state.settings.staleHours*3600000);
|
||||
const rows:FatigueResult[]=[]; let excluded=data.excluded;
|
||||
for(const c of data.map.values()) {
|
||||
const campaign=campaigns.find(x=>x.id===c.campaignId);
|
||||
if(!campaign) {excluded++;continue;}
|
||||
const blocked=!campaign.active?"Campaign 未启用,不判定素材疲劳":campaign.pending||!campaign.synced||!Number.isFinite(Date.parse(campaign.synced))||now-Date.parse(campaign.synced)>this.state.settings.staleHours*3600000?"Campaign 同步过期或有未发布修改,需先核实":undefined;
|
||||
const r=evaluateFatigue(c,split,this.state.settings,Boolean(fresh),blocked);
|
||||
const key=JSON.stringify(["fatigue",a.tenant_id,a.external_account_id,to,c.campaignId,c.id,c.kind,c.variant,r.state,this.state.settings.fatigueCtrDrop,this.state.settings.fatigueViewDrop,this.state.settings.fatigueMinClicks,this.state.settings.minImpressions]);
|
||||
rows.push({...r,key,handled:this.state.handled.includes(key),period,account:{id:a.external_account_id,tenant:a.tenant_id,name:a.account_name,timezone:a.reporting_timezone,currency:a.account_currency}});
|
||||
}
|
||||
return {rows,excluded};
|
||||
}
|
||||
private async collect(a: z.infer<typeof accountSchema>, org: string) {
|
||||
const campaigns: z.infer<typeof campaignSchema>[] = [];
|
||||
let received = 0;
|
||||
for(let page=1;page<=100;page++) {
|
||||
const list=z.object({items:z.array(z.object({subject_type:z.string()}).passthrough()),total:z.number(),account_context:z.object({external_account_id:z.string(),tenant_id:z.string()})}).parse(await this.read("campaign_list",{page,page_size:100,external_account_id:a.external_account_id,tenant_id:a.tenant_id}));
|
||||
if(list.account_context.external_account_id!==a.external_account_id||list.account_context.tenant_id!==a.tenant_id) throw new Error("Campaign 响应账户不符,结果已丢弃");
|
||||
received += list.items.length;
|
||||
campaigns.push(...list.items.filter(c=>c.subject_type==="commerce_shop").map(c=>campaignSchema.parse(c)));
|
||||
if(received>=list.total) break;
|
||||
if(!list.items.length || page===100) throw new Error("Campaign 分页不完整");
|
||||
}
|
||||
if(campaigns.some(c=>c.external_account_id!==a.external_account_id)) throw new Error("Campaign 账户校验失败");
|
||||
if(new Set(campaigns.map(c=>c.external_campaign_id)).size!==campaigns.length) throw new Error("Campaign 分页重复,结果已丢弃");
|
||||
const date = (ago: number) => new Date(Date.now()+a.reporting_utc_offset_minutes*60000-ago*86400000).toISOString().slice(0,10);
|
||||
const period=date(1), before=date(2);
|
||||
if(!campaigns.length)return {rows:[],period,excluded:received};
|
||||
const query = async (day:string) => decodeMetrics(await this.read("metrics_semantic_query",{query:{tenant_id:a.tenant_id,org_id:org,platform:a.platform,account_id:a.external_account_id,query_kind:"campaign_breakdown",metrics:["spend","gross_revenue","orders","product_impressions","product_clicks"],dimensions:["external_campaign_id"],date_from:day,date_to:day,reporting_timezone:a.reporting_timezone,reporting_utc_offset_minutes:a.reporting_utc_offset_minutes,limit:5000}}));
|
||||
const current=await query(period), previous=await query(before);
|
||||
const end=z.object({access:z.object({current_account:accountSchema})}).parse(await this.read("swads_whoami"));
|
||||
if(end.access.current_account.external_account_id!==a.external_account_id || end.access.current_account.tenant_id!==a.tenant_id) throw new Error("检查期间账户改变,结果已丢弃");
|
||||
const now=Date.now();
|
||||
const fresh=[current,previous].every(data=>data.resolutions.length>0 && data.resolutions.every(r=>r.status==="available"&&r.freshness&&Number.isFinite(Date.parse(r.freshness))&&now-Date.parse(r.freshness)>=-300000&&now-Date.parse(r.freshness)<=this.state.settings.staleHours*3600000));
|
||||
const rows=campaigns.filter(c=>c.subject_type==="commerce_shop").map(c=>{
|
||||
const summary=c.commerce_summary;
|
||||
const r=evaluate({id:c.external_campaign_id,name:c.campaign_name,target:summary?.target_roi??null,budget:summary?.daily_budget??null,active:c.operation_status==="ENABLE",synced:c.last_synced_at,started:summary?.schedule_start_at??null,pending:(c.pending_workspace_changes?.total??0)>0,metrics:current.map.get(c.external_campaign_id),previous:previous.map.get(c.external_campaign_id)},this.state.settings,now,Boolean(fresh));
|
||||
const key=[a.tenant_id,a.external_account_id,period,r.id,r.state,r.target].join(":");
|
||||
return {...r,key,handled:this.state.handled.includes(key)};
|
||||
});
|
||||
return {rows,period,excluded:received-campaigns.length};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect,it } from "vitest";
|
||||
import { decodeCreatives,evaluateFatigue,type Creative } from "../src/fatigue.js";
|
||||
import {defaults,settingsSchema} from "../src/monitor.js";
|
||||
const creative:Creative={id:"video",campaignId:"c",kind:"item",variant:"ADS_AND_ORGANIC",points:Array.from({length:10},(_,i)=>({date:`2026-09-${String(i+1).padStart(2,"0")}`,impressions:1000,clicks:i<7?100:50,plays:1000,views:i<7?500:200}))};
|
||||
it("requires corroborating viewing decline, not CTR alone",()=>{
|
||||
const r=evaluateFatigue(creative,"2026-09-08",defaults,true);
|
||||
expect(r.state).toBe("suspected_fatigue");expect(r.ctrDrop).toBe(50);expect(r.baseline.ctr).toBe(.1);
|
||||
expect(evaluateFatigue({...creative,points:creative.points.map(p=>({...p,plays:0,views:0}))},"2026-09-08",defaults,true).state).toBe("creative_decline");
|
||||
});
|
||||
it("fails closed on stale, pending, sparse and missing baseline",()=>{
|
||||
expect(evaluateFatigue(creative,"2026-09-08",defaults,false).state).toBe("data_issue");
|
||||
expect(evaluateFatigue(creative,"2026-09-08",defaults,true,"配置修改中").state).toBe("data_issue");
|
||||
expect(evaluateFatigue({...creative,points:creative.points.slice(7)},"2026-09-08",defaults,true).state).toBe("observe");
|
||||
expect(evaluateFatigue({...creative,points:creative.points.map(p=>({...p,impressions:100,clicks:1}))},"2026-09-08",defaults,true).state).toBe("observe");
|
||||
});
|
||||
it("uses weighted rates and allows zero recent clicks to trigger decline",()=>{
|
||||
const r=evaluateFatigue({...creative,points:creative.points.map((p,i)=>({...p,impressions:i===0?10000:1000,clicks:i<7?100:0}))},"2026-09-08",defaults,true);
|
||||
expect(r.baseline.ctr).toBe(700/16000);expect(r.ctrDrop).toBe(100);
|
||||
});
|
||||
it("migrates old settings and rejects invalid thresholds",()=>{
|
||||
expect(settingsSchema.parse({enabled:false,minSpend:100,minOrders:5,minImpressions:1000,staleHours:12}).fatigueCtrDrop).toBe(25);
|
||||
expect(()=>settingsSchema.parse({...defaults,fatigueCtrDrop:101})).toThrow();
|
||||
});
|
||||
it("validates scope, duplicates, dates and truncation; excludes product cards",()=>{
|
||||
const raw={columns:["external_campaign_id","external_creative_id","creative_identity_kind","creative_variant","date","product_impressions","product_clicks","video_play_actions","video_watched_6s"].map(name=>({name})),rows:[["c","v","item","ADS","2026-09-01",100,5,0,0],["c","p","product_card","ADS","2026-09-01",100,5,0,0]],account_context:{external_account_id:"a",tenant_id:"t"},query_meta:{truncated:false}};
|
||||
const decode=(r:typeof raw)=>decodeCreatives(r,"a","t","2026-09-01","2026-09-10");
|
||||
expect(decode(raw).map.size).toBe(1);expect(decode(raw).excluded).toBe(1);
|
||||
expect(()=>decode({...raw,rows:[raw.rows[0]!,raw.rows[0]!]})).toThrow("重复");
|
||||
expect(()=>decode({...raw,query_meta:{truncated:true}})).toThrow("截断");
|
||||
expect(()=>decode({...raw,account_context:{external_account_id:"b",tenant_id:"t"}})).toThrow("账户");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { it,expect } from "vitest";
|
||||
import { evaluate,defaults,decodeMetrics,AdsMonitor,type Campaign } from "../src/monitor.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
const now=Date.parse("2026-09-09T08:00:00Z");
|
||||
const base:Campaign={id:"1",name:"test",target:2,active:true,synced:"2026-09-09T07:00:00Z",started:"2026-09-01T00:00:00Z",pending:false,budget:100,metrics:{spend:100,revenue:300,orders:10,impressions:2000,clicks:100},previous:{spend:100,revenue:250,orders:10,impressions:2000,clicks:100}};
|
||||
it("uses each campaign target, never global 2.5",()=>{expect(evaluate(base,defaults,now,true).state).toBe("potential");expect(evaluate({...base,target:5},defaults,now,true).state).toBe("under_target");expect(evaluate({...base,target:null},defaults,now,true).state).toBe("target_missing");});
|
||||
it("does not confuse missing, stale or small data with loss",()=>{expect(evaluate({...base,metrics:undefined},defaults,now,true).state).toBe("data_issue");expect(evaluate(base,defaults,now,false).state).toBe("data_issue");expect(evaluate({...base,metrics:{...base.metrics!,spend:10}},defaults,now,true).state).toBe("observe");expect(evaluate({...base,pending:true},defaults,now,true).state).toBe("data_issue");});
|
||||
it("calculates ratios from totals and handles zero orders",()=>{const r=evaluate(base,defaults,now,true);expect(r.roi).toBe(3);expect(r.cpa).toBe(10);expect(r.ctr).toBe(.05);expect(evaluate({...base,metrics:{...base.metrics!,orders:0,revenue:0}},defaults,now,true).state).toBe("no_orders");});
|
||||
it("blocks truncated metrics",()=>{expect(()=>decodeMetrics({columns:[],rows:[],query_meta:{truncated:true}})).toThrow("截断");});
|
||||
it("isolates all TikTok accounts, restores selection, persists and deduplicates",async()=>{
|
||||
const dir=await mkdtemp(path.join(tmpdir(),"ads-monitor-test-"));
|
||||
let id="account-1";
|
||||
let fail=false;
|
||||
const account=(external_account_id:string)=>({external_account_id,account_name:external_account_id,tenant_id:"tenant",platform:"tiktok",reporting_timezone:"Asia/Shanghai",reporting_utc_offset_minutes:480,account_currency:external_account_id==="account-1"?"CNY":"USD"});
|
||||
const read=async(name:string,args:Record<string,unknown>={})=>{
|
||||
if(name==="swads_whoami")return {access:{org_id:"org",current_account:account(id),accounts:[account("account-1"),account("account-2"),{...account("other"),platform:"qianchuan"}]}};
|
||||
if(name==="campaign_list"){
|
||||
id=String(args.external_account_id);
|
||||
if(fail&&id==="account-2")throw new Error("账户权限不足");
|
||||
return {total:2,account_context:{external_account_id:id,tenant_id:"tenant"},items:[{subject_type:"minis",external_campaign_id:null,external_account_id:null},{subject_type:"commerce_shop",external_campaign_id:"1",external_account_id:id,campaign_name:"test",operation_status:"ENABLE",last_synced_at:new Date().toISOString(),commerce_summary:{target_roi:id==="account-1"?2:5,schedule_start_at:"2020-01-01"}}]};
|
||||
}
|
||||
const query=args.query as {query_kind?:string;date_from?:string}|undefined;
|
||||
if(query?.query_kind==="creative_breakdown") {
|
||||
const recent=query.date_from!>=new Date(Date.now()+480*60000-3*86400000).toISOString().slice(0,10);
|
||||
return {columns:["external_campaign_id","external_creative_id","creative_identity_kind","creative_variant","date","product_impressions","product_clicks","video_play_actions","video_watched_6s"].map(name=>({name})),rows:[["1","v","item","ADS_AND_ORGANIC",query.date_from,1000,recent?50:100,0,0]],account_context:{external_account_id:id,tenant_id:"tenant"},query_meta:{truncated:false,attribution_resolutions:[{status:"available",freshness:new Date().toISOString()}]}};
|
||||
}
|
||||
return {columns:["external_campaign_id","spend","gross_revenue","orders","product_impressions","product_clicks"].map(name=>({name})),rows:[["1",100,300,10,2000,100]],query_meta:{truncated:false,attribution_resolutions:[{status:"available",freshness:new Date().toISOString()}]}};
|
||||
};
|
||||
const m=new AdsMonitor(dir,read);
|
||||
const restored=new AdsMonitor(dir,read);
|
||||
try{
|
||||
await m.init();expect((await m.check()).newAlerts).toBe(4);expect(m.state.rows).toHaveLength(2);expect(id).toBe("account-1");
|
||||
expect(m.state.fatigueRows).toHaveLength(2);expect(m.state.fatigueRows?.[0]?.state).toBe("creative_decline");
|
||||
expect(m.state.fatigueRows?.[0]?.baseline.days).toBe(7);expect(m.state.fatigueRows?.[0]?.recent.days).toBe(3);
|
||||
await m.handle(m.state.fatigueRows![0]!.key);
|
||||
expect(m.state.rows.map(r=>r.state)).toEqual(["potential","under_target"]);expect(new Set(m.state.rows.map(r=>r.key)).size).toBe(2);
|
||||
await m.handle(m.state.rows[0]!.key);expect((await m.check()).newAlerts).toBe(0);
|
||||
await restored.init();expect(restored.state.rows[0]!.handled).toBe(true);expect(restored.state.fatigueRows?.[0]?.handled).toBe(true);
|
||||
fail=true;expect((await restored.check()).error).toContain("1 个账户检查失败");expect(restored.state.rows).toHaveLength(1);expect(restored.state.accounts?.[1]?.error).toContain("权限");expect(id).toBe("account-1");
|
||||
}finally{m.close();restored.close();await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
Reference in New Issue
Block a user