From 7c744b98a0c36097e648e0063915e7a8dd5d53e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:46:36 +0000 Subject: [PATCH] feat(media): per-segment results and copy checks on every field --- .../src/media-marketing.postgres.test.ts | 11 ++- packages/adapters/src/media-metrics.ts | 95 +++++++++++++++++-- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/adapters/src/media-marketing.postgres.test.ts b/packages/adapters/src/media-marketing.postgres.test.ts index 892847c..54f1081 100644 --- a/packages/adapters/src/media-marketing.postgres.test.ts +++ b/packages/adapters/src/media-marketing.postgres.test.ts @@ -23,7 +23,12 @@ import type { import { EmulatorMediaInferenceProvider } from "./media-emulator.js"; import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js"; import { storeMediaArtifact } from "./media-marketing-files.js"; -import { recordMetric, refitTeaserModel, resetTeaserModel } from "./media-metrics.js"; +import { + recordMetric, + refitTeaserModel, + resetTeaserModel, + segmentFunnel, +} from "./media-metrics.js"; import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js"; import { bundleRelativeDir } from "./media-publish.js"; import { BundleMediaPublisher } from "./media-publisher-bundle.js"; @@ -934,6 +939,10 @@ describePostgres("media marketing on Postgres", () => { conversions: 30, revenueCents: 29_970, }); + const bySegment = await segmentFunnel(deps, actor, project.id); + expect(bySegment.map((row) => row.segmentId)).toEqual([strong.id, weak.id]); + expect(bySegment[0]).toMatchObject({ publications: 10, views: 10_000, conversions: 300 }); + expect(bySegment[1]).toMatchObject({ publications: 10, conversions: 20 }); await resetTeaserModel(deps, actor); expect(await repos.getTeaserModel(actor.spaceId)).toBeNull(); }); diff --git a/packages/adapters/src/media-metrics.ts b/packages/adapters/src/media-metrics.ts index 1e6ed08..8f212fe 100644 --- a/packages/adapters/src/media-metrics.ts +++ b/packages/adapters/src/media-metrics.ts @@ -1,4 +1,4 @@ -import type { Edl, MediaMetric, MediaSegment } from "@rakazo/contracts"; +import type { Edl, FunnelRow, MediaMetric, MediaSegment } from "@rakazo/contracts"; import { TEASER_FEATURES, TEASER_LEARNING_MIN_SAMPLES } from "@rakazo/contracts"; import type { NewMediaMetric, TeaserModelRecord } from "@rakazo/db"; import { InvalidMediaInputError } from "@rakazo/db"; @@ -57,17 +57,16 @@ export function planFeatures(edl: Edl, segments: MediaSegment[]): SegmentFeature ) as SegmentFeatures; } -/** Refit and store the space's teaser weights; null while there are too few results. */ -export async function refitTeaserModel( +/** Funnel rows with the plan each publication's render used, and those plans' segments. */ +async function withPlans( deps: Pick, spaceId: string, -): Promise { + rows: FunnelRow[], +): Promise<{ samples: Array; segments: MediaSegment[] }> { const { repos } = deps; - const rows = (await repos.funnel(spaceId)).filter((row) => row.views > 0); - if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null; const plans = new Map(); - const samples: Array<{ edl: Edl; views: number; conversions: number }> = []; const planOfRender = new Map(); + const samples: Array = []; for (const row of rows) { const publication = await repos.getPublication(spaceId, row.publicationId); let planId = planOfRender.get(publication.renderId); @@ -80,12 +79,90 @@ export async function refitTeaserModel( edl = (await repos.getPlan(spaceId, planId)).edl; plans.set(planId, edl); } - samples.push({ edl, views: row.views, conversions: row.conversions }); + samples.push({ ...row, edl }); } const assetIds = [ ...new Set(samples.flatMap((s) => s.edl.tracks.video.map((clip) => clip.assetId))), ]; - const segments = await repos.listSegments(spaceId, assetIds); + return { samples, segments: await repos.listSegments(spaceId, assetIds) }; +} + +export interface SegmentFunnelRow { + segmentId: string; + assetId: string; + startMs: number; + endMs: number; + publications: number; + views: number; + clicks: number; + conversions: number; + revenueCents: number; + currency: string; +} + +/** + * Results per teaser segment (spec 10.6): every publication counts fully for + * each segment its plan used. Rows are per currency so revenue never mixes. + */ +export async function segmentFunnel( + deps: Pick, + actor: MediaActor, + projectId?: string, +): Promise { + const rows = await deps.repos.funnel(actor.spaceId, projectId); + const { samples, segments } = await withPlans(deps, actor.spaceId, rows); + const byId = new Map(segments.map((segment) => [segment.id, segment])); + const totals = new Map(); + for (const sample of samples) { + const used = new Set(); + for (const clip of sample.edl.tracks.video) { + for (const segment of segments) { + const linked = clip.segmentId + ? segment.id === clip.segmentId + : segment.assetId === clip.assetId && + segment.startMs < clip.outMs && + segment.endMs > clip.inMs; + if (linked) used.add(segment.id); + } + } + for (const segmentId of used) { + const segment = byId.get(segmentId)!; + const key = `${segmentId}\0${sample.currency}`; + const row = totals.get(key) ?? { + segmentId, + assetId: segment.assetId, + startMs: segment.startMs, + endMs: segment.endMs, + publications: 0, + views: 0, + clicks: 0, + conversions: 0, + revenueCents: 0, + currency: sample.currency, + }; + row.publications += 1; + row.views += sample.views; + row.clicks += sample.clicks; + row.conversions += sample.conversions; + row.revenueCents += sample.revenueCents; + totals.set(key, row); + } + } + return [...totals.values()].sort( + (a, b) => + b.conversions - a.conversions || b.views - a.views || a.segmentId.localeCompare(b.segmentId), + ); +} + +/** Refit and store the space's teaser weights; null while there are too few results. */ +export async function refitTeaserModel( + deps: Pick, + spaceId: string, +): Promise { + const { repos } = deps; + const rows = (await repos.funnel(spaceId)).filter((row) => row.views > 0); + if (rows.length < TEASER_LEARNING_MIN_SAMPLES) return null; + const { samples, segments } = await withPlans(deps, spaceId, rows); const usable = samples.flatMap((sample) => { const features = planFeatures(sample.edl, segments); return features