@aicopycoders/exodus 2026.7.200-beta.7 → 2026.7.200-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,160 +0,0 @@
1
- # Own-Brand Winners — enrichment path design (wayfinder #565)
2
-
3
- Designed 2026-07-14 from the [MCP probe findings](meta-ads-mcp-probe.md) (#563)
4
- plus a code audit of the live swipe rail (`scout/src/scrapecreators.ts`,
5
- `scout/src/trigger/swipe-mine.ts`, `swipe-transcribe.ts`, `image-classify.ts`,
6
- `convex/swipeAds.ts`, `convex/imageClassification.ts`). Answers: what is the
7
- exact path from a designated winner (ad-account identity) to a complete stored
8
- package? Feeds the bridge contract (#566) and the build specs (#567).
9
-
10
- ## TL;DR decisions
11
-
12
- 1. **Match server-side on ad copy, not agent-side on the MCP title fingerprint.**
13
- The bridge payload carries the account-side ground truth (body, title,
14
- created_time, format, page id); a new import task scrapes the brand's own
15
- page once via the existing `fetchCompanyAds` and matches winners to library
16
- ads by **normalized body+title equality** (tiebreak: earliest `start_date`).
17
- Full ScrapeCreators snapshots include body text — a far stronger key than
18
- the library-search `title + time` fingerprint the probe had to settle for,
19
- and it drops `ads_library_search` from the skill entirely.
20
- 2. **One new Trigger task (`own-brand-import`), everything downstream as-is.**
21
- The task reuses the scrapecreators.ts helpers + the `/api/v2/swipes/batch`
22
- upsert, stores **only matched winners**, then fires the existing
23
- `swipe-transcribe` and `image-classify` sweeps, which pick own-brand rows up
24
- with zero changes (both sweep global "missing transcript" / "unclassified"
25
- states).
26
- 3. **Gap-filler = agent-pushed assets for unmatched winners** (ad stopped
27
- running): text is always complete from MCP; images are downloaded by the
28
- agent immediately (signed CDN URLs expire) and uploaded as bytes via the
29
- existing Convex `generateUploadUrl` pattern; **video files have no MCP
30
- source — the skill asks the user for their own creative file** (it's their
31
- ad), else the package is stored partial.
32
- 4. **Mirror winner media into Convex storage at import.** fbcdn URLs expire;
33
- competitor rows survive because the daily re-scrape refreshes them, but
34
- winners are long-lived fuel imported once. Store the storage **serve URL in
35
- the existing `imageUrl`/`videoUrl` fields** so every downstream consumer
36
- (pickers, classify, transcribe, gallery) works unchanged.
37
- 5. **Own-brand `brands` rows must be flagged out of the daily swipe-mine cron**
38
- (`isOwnBrand: true`, mine filters it): otherwise the nightly sweep scrapes
39
- the user's whole page and stores every active ad — violating winners-only.
40
- 6. **Completeness is an explicit stored field**, not derived: `complete` |
41
- `partial` (+ what's missing), surfaced amber in the UI tab (the #478
42
- partial-delivery pattern).
43
-
44
- ## Stage 0 — what the agent holds per winner (input to the bridge)
45
-
46
- Per the probe, after the winner interview designates ads, the agent can gather
47
- via MCP (`ads_get_ad_entities` + `ads_get_creatives` + `ads_get_ad_images` /
48
- `ads_get_ad_videos`, all with explicit `fields`):
49
-
50
- | Field | Source | Notes |
51
- |---|---|---|
52
- | account ad id, name, campaign/adset ids | entities | hierarchy + provenance |
53
- | `created_time` | entities | matching tiebreak + daysRunning fallback |
54
- | body (primary text), title (headline), CTA type | creative | ground truth for matching |
55
- | `effective_object_story_id` (`<pageId>_<postId>`) | creative | **page id prefix** = scrape target |
56
- | image hash → full-res signed URL | ad_images | **download immediately**, expires |
57
- | video id, length, poster URL | ad_videos | no source URL — cannot download |
58
- | metrics as display strings + result-type labels | entities | verdict snapshot material |
59
-
60
- The bridge payload (#566) should carry all of the above per winner, plus the
61
- page id once per import.
62
-
63
- ## Stage 1 — matching: winner → `adArchiveId`
64
-
65
- Runs inside `own-brand-import` (server-side, ScrapeCreators BYOK key snapshot
66
- exactly like `swipe-mine`'s manual-run pattern):
67
-
68
- 1. `fetchCompanyAds(pageId)` — one paginated scrape of the user's own page.
69
- Returns full snapshots: `body.text`, `title`, `start_date`, media URLs,
70
- `collation_count`.
71
- 2. For each winner: **normalize** (trim, collapse whitespace, strip zero-width
72
- chars, normalize curly quotes) and match on **body equality**; require
73
- title equality too when both sides have a title.
74
- 3. Ambiguity: multiple library ads with identical copy are creative variants /
75
- republishes — any is acceptable (same creative); pick the **earliest
76
- `start_date`** (longest `daysRunning`, best longevity signal for pickers).
77
- 4. Fallback when the account creative has no body (some `SHARE`-type creatives
78
- keep copy on the story post): match on unique title + `created_time`
79
- proximity (±72h). Non-unique → treat as unmatched (gap-filler), never guess.
80
- 5. Matched → optionally `fetchAdLibraryAdById(archiveId, get_transcript=true)`
81
- for the inline transcript (the idea-ad-library / workflow-executor pattern);
82
- store it at insert so most video winners never wait for the transcribe cron.
83
-
84
- **Failure modes:** page scrape empty (wrong page id, zero active ads) → all
85
- winners unmatched → gap-filler; the Ad Library only lists **running** ads for
86
- non-political advertisers, so any stopped winner is *expected* to be unmatched
87
- — that's the designed-for case, not an error. One spec-time item:
88
- `fetchCompanyAds` hardcodes `country: "US"` — the import path must make this
89
- configurable (or "ALL" if the endpoint accepts it) or non-US advertisers will
90
- false-unmatch.
91
-
92
- ## Stage 2 — gap-filler protocol (unmatched winners)
93
-
94
- What MCP can and cannot supply fills the row directly:
95
-
96
- - **Copy**: body/title/CTA come from the creative — always complete. No
97
- library `linkUrl`/`linkDescription`; acceptable loss (fields stay empty).
98
- - **Image**: the agent downloads the signed CDN URL **at interview time** (not
99
- at push time — it expires) and the CLI uploads bytes: request an upload URL
100
- (the `creativeSuite.generateUploadUrl` service pattern), PUT the file, record
101
- the storage id. Import stores the serve URL in `imageUrl`.
102
- - **Video**: no MCP file. Ladder: (a) the skill asks the user to point at
103
- their own creative file — they made the ad, they usually have it — CLI
104
- uploads it; (b) no file → store partial: copy + poster image (poster URL
105
- *is* downloadable now) and no transcript. Once a video lands in storage, its
106
- serve URL in `videoUrl` makes the existing `pendingTranscripts` sweep
107
- transcribe it automatically (note: `transcribeVideo` rejects >25MB —
108
- acceptable v1 cap, flag oversize uploads as partial-transcript).
109
- - **Identity**: gap-filled rows get a synthetic archive id `own:<accountAdId>`
110
- — stable, unique, satisfies the `by_adArchiveId` dedup index, can't collide
111
- with numeric library ids. `isActive: false`, `startDate` from
112
- `created_time`.
113
-
114
- ## Stage 3 — reuse audit
115
-
116
- | Piece | Verdict |
117
- |---|---|
118
- | `scrapecreators.ts` helpers (fetch, extract*, classifyFormat, transcribe) | **as-is** — the import task composes them |
119
- | `/api/v2/swipes/batch` → `swipeAds.upsertBatch` | **as-is** — winners-only array in, dedup by archive id |
120
- | `swipe-transcribe` (`pendingTranscripts` sweep) | **as-is** — global format=video-missing-transcript scan includes own-brand rows; also fire it at import end |
121
- | `image-classify` (`getUnclassifiedSwipeAds`) | **as-is** — sweeps `classificationStatus === undefined`; own-brand rows enter automatically |
122
- | Consumption (`swipeAdToText`, pickers, `listForWorkspacePublic`) | **as-is** — own-brand rows are swipeAds under a workspace brand; they flow into every picker. Spec check (per map): run-dialog top-N ranks by `daysRunning` — matched winners inherit real longevity, gap-filled ones get `created_time`-derived values, and the winner fields below let pickers boost designated winners explicitly |
123
- | `swipe-mine` task | **not reused** (fetch-everything shape violates winners-only); its building blocks are |
124
- | Daily `swipe-mine` cron | **needs a filter**: skip `isOwnBrand` brands (decision 5) |
125
- | New code | one Trigger task `own-brand-import` + one HTTP route to start it (BYOK key snapshot like `swipe.startMineRun`) + the CLI ingestion command (#566's contract) |
126
-
127
- ## Stage 4 — completeness contract
128
-
129
- A package is **usable generative fuel** when:
130
-
131
- - **every winner**: verdict snapshot (composed sentence + metric display
132
- strings) — non-negotiable, it's the point of the feature — plus body or
133
- title, and provenance (account ad id, campaign/adset ids).
134
- - **image winner**: + full-res image (storage-mirrored). Missing image →
135
- `partial` (copy bots still work; ref-match image engines can't use it).
136
- - **video winner**: + transcript (from library inline, sweep, or uploaded
137
- file). Video file itself is desired (gallery, future variations) but
138
- transcript is the fuel; no transcript AND no video → `partial`.
139
-
140
- Store it explicitly: `enrichmentStatus: "complete" | "partial"` +
141
- `enrichmentMissing: string[]` (e.g. `["video-file", "transcript"]`), set by the
142
- import task, re-evaluated when a sweep fills a gap. UI shows amber on partial
143
- rows with the missing list (the #478 pattern).
144
-
145
- New optional `swipeAds` columns this implies for #567 (all additive):
146
- winner verdict text + metrics snapshot, `designatedAt`, source account
147
- ad/campaign/adset ids, `enrichmentSource: "library-match" | "agent-push"`,
148
- `enrichmentStatus` / `enrichmentMissing`, `imageStorageId` / `videoStorageId`
149
- (lifecycle), plus `brands.isOwnBrand`.
150
-
151
- ## Open items handed to #566 / #567
152
-
153
- - Bridge contract (#566): exact payload shape per winner (Stage 0 table), how
154
- the CLI transports image/video bytes (upload-URL handshake vs multipart),
155
- idempotency on re-push (upsert by account ad id → synthetic/matched archive
156
- id), and where the "ask the user for the video file" step lives in the skill.
157
- - Spec (#567): country parameter on the import scrape; winner-boost rule in
158
- top-selection heuristics; amber partial UI in the Swipe Mining tab; whether
159
- matched winners' media also gets storage-mirrored at import (recommended:
160
- yes, both paths, decision 4).