Enhance Beryl Agent detail and report views

This commit is contained in:
Beryl
2026-09-11 11:13:24 +08:00
parent 336bf88466
commit 15f067fff9
57 changed files with 1833 additions and 95 deletions
+33 -3
View File
@@ -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}};
+1 -1
View File
@@ -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});}
});