thevoidforge 21.0.11 → 21.0.13
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.
- package/dist/.claude/commands/ai.md +69 -0
- package/dist/.claude/commands/architect.md +121 -0
- package/dist/.claude/commands/assemble.md +201 -0
- package/dist/.claude/commands/assess.md +75 -0
- package/dist/.claude/commands/blueprint.md +135 -0
- package/dist/.claude/commands/build.md +116 -0
- package/dist/.claude/commands/campaign.md +201 -0
- package/dist/.claude/commands/cultivation.md +166 -0
- package/dist/.claude/commands/current.md +128 -0
- package/dist/.claude/commands/dangerroom.md +74 -0
- package/dist/.claude/commands/debrief.md +178 -0
- package/dist/.claude/commands/deploy.md +99 -0
- package/dist/.claude/commands/devops.md +143 -0
- package/dist/.claude/commands/gauntlet.md +140 -0
- package/dist/.claude/commands/git.md +104 -0
- package/dist/.claude/commands/grow.md +146 -0
- package/dist/.claude/commands/imagine.md +126 -0
- package/dist/.claude/commands/portfolio.md +50 -0
- package/dist/.claude/commands/prd.md +113 -0
- package/dist/.claude/commands/qa.md +107 -0
- package/dist/.claude/commands/review.md +151 -0
- package/dist/.claude/commands/security.md +100 -0
- package/dist/.claude/commands/test.md +96 -0
- package/dist/.claude/commands/thumper.md +116 -0
- package/dist/.claude/commands/treasury.md +100 -0
- package/dist/.claude/commands/ux.md +118 -0
- package/dist/.claude/commands/vault.md +189 -0
- package/dist/.claude/commands/void.md +108 -0
- package/dist/CHANGELOG.md +1918 -0
- package/dist/CLAUDE.md +250 -0
- package/dist/HOLOCRON.md +856 -0
- package/dist/VERSION.md +123 -0
- package/dist/docs/NAMING_REGISTRY.md +478 -0
- package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
- package/dist/docs/methods/ASSEMBLER.md +142 -0
- package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
- package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
- package/dist/docs/methods/CAMPAIGN.md +568 -0
- package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
- package/dist/docs/methods/DEEP_CURRENT.md +184 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
- package/dist/docs/methods/FIELD_MEDIC.md +261 -0
- package/dist/docs/methods/FORGE_ARTIST.md +108 -0
- package/dist/docs/methods/FORGE_KEEPER.md +268 -0
- package/dist/docs/methods/GAUNTLET.md +344 -0
- package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
- package/dist/docs/methods/HEARTBEAT.md +168 -0
- package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
- package/dist/docs/methods/MUSTER.md +148 -0
- package/dist/docs/methods/PRD_GENERATOR.md +186 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
- package/dist/docs/methods/QA_ENGINEER.md +337 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
- package/dist/docs/methods/SUB_AGENTS.md +335 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
- package/dist/docs/methods/TESTING.md +359 -0
- package/dist/docs/methods/THUMPER.md +175 -0
- package/dist/docs/methods/TIME_VAULT.md +120 -0
- package/dist/docs/methods/TREASURY.md +184 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
- package/dist/docs/patterns/README.md +52 -0
- package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
- package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
- package/dist/docs/patterns/ai-classifier.ts +195 -0
- package/dist/docs/patterns/ai-eval.ts +272 -0
- package/dist/docs/patterns/ai-orchestrator.ts +341 -0
- package/dist/docs/patterns/ai-router.ts +194 -0
- package/dist/docs/patterns/ai-tool-schema.ts +237 -0
- package/dist/docs/patterns/api-route.ts +241 -0
- package/dist/docs/patterns/backtest-engine.ts +499 -0
- package/dist/docs/patterns/browser-review.ts +292 -0
- package/dist/docs/patterns/combobox.tsx +300 -0
- package/dist/docs/patterns/component.tsx +262 -0
- package/dist/docs/patterns/daemon-process.ts +338 -0
- package/dist/docs/patterns/data-pipeline.ts +297 -0
- package/dist/docs/patterns/database-migration.ts +466 -0
- package/dist/docs/patterns/e2e-test.ts +629 -0
- package/dist/docs/patterns/error-handling.ts +312 -0
- package/dist/docs/patterns/execution-safety.ts +601 -0
- package/dist/docs/patterns/financial-transaction.ts +342 -0
- package/dist/docs/patterns/funding-plan.ts +462 -0
- package/dist/docs/patterns/game-entity.ts +137 -0
- package/dist/docs/patterns/game-loop.ts +113 -0
- package/dist/docs/patterns/game-state.ts +143 -0
- package/dist/docs/patterns/job-queue.ts +225 -0
- package/dist/docs/patterns/kongo-integration.ts +164 -0
- package/dist/docs/patterns/middleware.ts +363 -0
- package/dist/docs/patterns/mobile-screen.tsx +139 -0
- package/dist/docs/patterns/mobile-service.ts +167 -0
- package/dist/docs/patterns/multi-tenant.ts +382 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
- package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
- package/dist/docs/patterns/prompt-template.ts +195 -0
- package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
- package/dist/docs/patterns/service.ts +224 -0
- package/dist/docs/patterns/sse-endpoint.ts +118 -0
- package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
- package/dist/docs/patterns/third-party-script.ts +68 -0
- package/dist/scripts/thumper/gom-jabbar.sh +241 -0
- package/dist/scripts/thumper/relay.sh +610 -0
- package/dist/scripts/thumper/scan.sh +359 -0
- package/dist/scripts/thumper/thumper.sh +190 -0
- package/dist/scripts/thumper/water-rings.sh +76 -0
- package/dist/wizard/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Ad Platform Adapter (Split Interface)
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Separate interactive setup (browser OAuth) from runtime operations (daemon)
|
|
6
|
+
* - AdPlatformSetup runs in CLI/Danger Room (interactive, user-present)
|
|
7
|
+
* - AdPlatformAdapter runs in heartbeat daemon (non-interactive, autonomous)
|
|
8
|
+
* - All amounts in integer cents (Cents branded type) — never float
|
|
9
|
+
* - Rate limiting per-platform with token bucket
|
|
10
|
+
* - Errors normalized to common PlatformError format
|
|
11
|
+
* - Idempotency keys on all write operations (WAL pattern per ADR-3)
|
|
12
|
+
*
|
|
13
|
+
* Agents: Breeze (platform relations), Wax (paid ads), Dockson (treasury)
|
|
14
|
+
*
|
|
15
|
+
* PRD Reference: §9.5, §9.19.10, §9.20.4
|
|
16
|
+
*
|
|
17
|
+
* Authorization Guard (§9.20.4):
|
|
18
|
+
* Daemon Tier 1 jobs receive ReadOnlyAdapter (pause, read operations only).
|
|
19
|
+
* Authenticated external commands receive full AdPlatformAdapter.
|
|
20
|
+
* See financial-transaction.ts for the Cents branded type.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ── Branded Financial Types (§9.17) ───────────────────
|
|
24
|
+
|
|
25
|
+
type Cents = number & { readonly __brand: 'Cents' };
|
|
26
|
+
type Percentage = number & { readonly __brand: 'Percentage' };
|
|
27
|
+
type Ratio = number & { readonly __brand: 'Ratio' };
|
|
28
|
+
|
|
29
|
+
function toCents(dollars: number): Cents {
|
|
30
|
+
return Math.round(dollars * 100) as Cents;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toDollars(cents: Cents): number {
|
|
34
|
+
return cents / 100;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Platform Types ────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
type AdPlatform = 'meta' | 'google' | 'tiktok' | 'linkedin' | 'twitter' | 'reddit' | 'snap';
|
|
40
|
+
|
|
41
|
+
interface OAuthTokens {
|
|
42
|
+
accessToken: string;
|
|
43
|
+
refreshToken: string;
|
|
44
|
+
expiresAt: string; // ISO 8601
|
|
45
|
+
platform: AdPlatform;
|
|
46
|
+
scopes: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ConnectionStatus {
|
|
50
|
+
connected: boolean;
|
|
51
|
+
accountId?: string;
|
|
52
|
+
accountName?: string;
|
|
53
|
+
currency?: string; // ISO 4217 — must be 'USD' per ADR-6
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Error Normalization ───────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface PlatformError {
|
|
60
|
+
platform: AdPlatform;
|
|
61
|
+
code: 'RATE_LIMITED' | 'AUTH_EXPIRED' | 'BUDGET_EXCEEDED' | 'CREATIVE_REJECTED' | 'UNKNOWN';
|
|
62
|
+
originalCode: number;
|
|
63
|
+
message: string;
|
|
64
|
+
retryable: boolean;
|
|
65
|
+
retryAfter?: number; // seconds
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Campaign Types ────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
interface CampaignConfig {
|
|
71
|
+
name: string;
|
|
72
|
+
platform: AdPlatform;
|
|
73
|
+
objective: 'awareness' | 'traffic' | 'conversions';
|
|
74
|
+
dailyBudget: Cents;
|
|
75
|
+
targeting: {
|
|
76
|
+
audiences: string[];
|
|
77
|
+
locations: string[];
|
|
78
|
+
ageRange?: [number, number];
|
|
79
|
+
interests?: string[];
|
|
80
|
+
};
|
|
81
|
+
creative: {
|
|
82
|
+
headlines: string[];
|
|
83
|
+
descriptions: string[];
|
|
84
|
+
callToAction: string;
|
|
85
|
+
landingUrl: string;
|
|
86
|
+
imageUrls?: string[];
|
|
87
|
+
};
|
|
88
|
+
testGroupId?: string;
|
|
89
|
+
testVariant?: string;
|
|
90
|
+
schedule?: {
|
|
91
|
+
startDate?: string;
|
|
92
|
+
endDate?: string;
|
|
93
|
+
};
|
|
94
|
+
idempotencyKey: string; // UUID, per ADR-3
|
|
95
|
+
complianceStatus: 'passed' | 'pending';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface CampaignResult {
|
|
99
|
+
externalId: string;
|
|
100
|
+
platform: AdPlatform;
|
|
101
|
+
status: 'created' | 'pending_review';
|
|
102
|
+
dashboardUrl: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface CampaignUpdate {
|
|
106
|
+
name?: string;
|
|
107
|
+
dailyBudget?: Cents;
|
|
108
|
+
targeting?: Partial<CampaignConfig['targeting']>;
|
|
109
|
+
schedule?: CampaignConfig['schedule'];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface CreativeConfig {
|
|
113
|
+
headlines?: string[];
|
|
114
|
+
descriptions?: string[];
|
|
115
|
+
callToAction?: string;
|
|
116
|
+
landingUrl?: string;
|
|
117
|
+
imageUrls?: string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface SpendReport {
|
|
121
|
+
platform: AdPlatform;
|
|
122
|
+
dateRange: { start: string; end: string };
|
|
123
|
+
totalSpend: Cents;
|
|
124
|
+
campaigns: Array<{
|
|
125
|
+
externalId: string;
|
|
126
|
+
spend: Cents;
|
|
127
|
+
impressions: number;
|
|
128
|
+
clicks: number;
|
|
129
|
+
conversions: number;
|
|
130
|
+
}>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface PerformanceMetrics {
|
|
134
|
+
campaignId: string;
|
|
135
|
+
impressions: number;
|
|
136
|
+
clicks: number;
|
|
137
|
+
conversions: number;
|
|
138
|
+
spend: Cents;
|
|
139
|
+
ctr: Percentage;
|
|
140
|
+
cpc: Cents;
|
|
141
|
+
roas: Ratio;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface InsightData {
|
|
145
|
+
campaignId: string;
|
|
146
|
+
metrics: Record<string, number>;
|
|
147
|
+
recommendations?: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Interactive Setup Interface ───────────────────────
|
|
151
|
+
// Runs in CLI or Danger Room — requires user interaction (browser OAuth)
|
|
152
|
+
|
|
153
|
+
interface AdPlatformSetup {
|
|
154
|
+
/** Interactive OAuth flow — opens browser for authorization */
|
|
155
|
+
authenticate(): Promise<OAuthTokens>;
|
|
156
|
+
|
|
157
|
+
/** Verify the connection works and return account info */
|
|
158
|
+
verifyConnection(tokens: OAuthTokens): Promise<ConnectionStatus>;
|
|
159
|
+
|
|
160
|
+
/** Detect account currency for ADR-6 enforcement */
|
|
161
|
+
detectCurrency(tokens: OAuthTokens): Promise<string>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Runtime Adapter Interface ─────────────────────────
|
|
165
|
+
// Runs in the heartbeat daemon — non-interactive, autonomous
|
|
166
|
+
|
|
167
|
+
interface AdPlatformAdapter {
|
|
168
|
+
// Token management
|
|
169
|
+
refreshToken(token: OAuthTokens): Promise<OAuthTokens>;
|
|
170
|
+
|
|
171
|
+
// Campaign CRUD (requires FullAdapter auth — see Authorization Guard)
|
|
172
|
+
createCampaign(config: CampaignConfig): Promise<CampaignResult>;
|
|
173
|
+
updateCampaign(id: string, changes: CampaignUpdate): Promise<void>;
|
|
174
|
+
pauseCampaign(id: string): Promise<void>;
|
|
175
|
+
resumeCampaign(id: string): Promise<void>;
|
|
176
|
+
deleteCampaign(id: string): Promise<void>;
|
|
177
|
+
|
|
178
|
+
// Budget and creative (requires FullAdapter auth)
|
|
179
|
+
updateBudget(id: string, dailyBudget: Cents): Promise<void>;
|
|
180
|
+
updateCreative(id: string, creative: CreativeConfig): Promise<void>;
|
|
181
|
+
|
|
182
|
+
// Reporting (read-only — available to ReadOnlyAdapter)
|
|
183
|
+
getSpend(dateRange: { start: string; end: string }): Promise<SpendReport>;
|
|
184
|
+
getPerformance(campaignId: string): Promise<PerformanceMetrics>;
|
|
185
|
+
getInsights(campaignId: string, metrics: string[]): Promise<InsightData>;
|
|
186
|
+
|
|
187
|
+
// Webhooks (deferred to remote mode per ADR-5)
|
|
188
|
+
handleWebhook?(payload: unknown): Promise<{ processed: boolean }>;
|
|
189
|
+
verifyWebhookSignature?(payload: Buffer, signature: string): boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Authorization Guard (§9.20.4) ─────────────────────
|
|
193
|
+
// Daemon Tier 1 jobs receive this restricted interface
|
|
194
|
+
|
|
195
|
+
type ReadOnlyAdapter = Pick<AdPlatformAdapter,
|
|
196
|
+
'pauseCampaign' | // Protective — stopping spend is always safe
|
|
197
|
+
'getSpend' |
|
|
198
|
+
'getPerformance' |
|
|
199
|
+
'getInsights' |
|
|
200
|
+
'refreshToken'
|
|
201
|
+
>;
|
|
202
|
+
|
|
203
|
+
// Authenticated external commands receive the full AdPlatformAdapter
|
|
204
|
+
//
|
|
205
|
+
// ── Adapter Caching Rule (field report #258) ────────────
|
|
206
|
+
// Stateful adapters (in-memory campaign store, e.g. sandbox) MUST be cached
|
|
207
|
+
// per platform — one instance per platform key. Creating a new instance per
|
|
208
|
+
// call loses state between operations (campaigns created become invisible).
|
|
209
|
+
// Stateless adapters (HTTP clients calling external APIs) can be created per
|
|
210
|
+
// call since they hold no local state. Use a module-level Map<string, Adapter>
|
|
211
|
+
// for caching when the adapter's constructor initializes mutable collections.
|
|
212
|
+
|
|
213
|
+
// ── Reference Implementation: Meta Marketing API ──────
|
|
214
|
+
|
|
215
|
+
class MetaAdapter implements AdPlatformSetup, AdPlatformAdapter {
|
|
216
|
+
private readonly baseUrl = 'https://graph.facebook.com/v19.0';
|
|
217
|
+
private rateLimiter: TokenBucketLimiter;
|
|
218
|
+
|
|
219
|
+
constructor(private adAccountId: string) {
|
|
220
|
+
// Meta: 200 calls/hr/ad account (sliding window)
|
|
221
|
+
this.rateLimiter = new TokenBucketLimiter({ capacity: 200, refillRate: 200 / 3600 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Setup (interactive) ──────────
|
|
225
|
+
|
|
226
|
+
async authenticate(): Promise<OAuthTokens> {
|
|
227
|
+
// 1. Open browser to Facebook Login
|
|
228
|
+
// 2. User authorizes → callback with short-lived token
|
|
229
|
+
// 3. Exchange for long-lived token (60 days)
|
|
230
|
+
// 4. Return OAuthTokens
|
|
231
|
+
throw new Error('Interactive OAuth — implement per platform');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async verifyConnection(tokens: OAuthTokens): Promise<ConnectionStatus> {
|
|
235
|
+
const res = await this.apiCall('GET', `/act_${this.adAccountId}`, tokens, {
|
|
236
|
+
fields: 'name,currency,account_status'
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
connected: true,
|
|
240
|
+
accountId: this.adAccountId,
|
|
241
|
+
accountName: res.name as string | undefined,
|
|
242
|
+
currency: res.currency as string | undefined,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async detectCurrency(tokens: OAuthTokens): Promise<string> {
|
|
247
|
+
const status = await this.verifyConnection(tokens);
|
|
248
|
+
return status.currency ?? 'USD';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Runtime (daemon) ─────────────
|
|
252
|
+
|
|
253
|
+
async refreshToken(token: OAuthTokens): Promise<OAuthTokens> {
|
|
254
|
+
// Meta long-lived tokens: exchange at 80% of 60-day TTL
|
|
255
|
+
const res = await this.apiCall('GET', '/oauth/access_token', token, {
|
|
256
|
+
grant_type: 'fb_exchange_token',
|
|
257
|
+
fb_exchange_token: token.accessToken,
|
|
258
|
+
});
|
|
259
|
+
return { ...token, accessToken: res.access_token as string, expiresAt: res.expires_at as string };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async createCampaign(config: CampaignConfig): Promise<CampaignResult> {
|
|
263
|
+
await this.rateLimiter.acquire();
|
|
264
|
+
const res = await this.apiCall('POST', `/act_${this.adAccountId}/campaigns`, undefined, {
|
|
265
|
+
name: config.name,
|
|
266
|
+
objective: this.mapObjective(config.objective),
|
|
267
|
+
status: 'PAUSED', // Create paused, activate after ad set + ad creation
|
|
268
|
+
special_ad_categories: [],
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
externalId: res.id as string,
|
|
272
|
+
platform: 'meta',
|
|
273
|
+
status: 'created',
|
|
274
|
+
dashboardUrl: `https://www.facebook.com/adsmanager/manage/campaigns?act=${this.adAccountId}&campaign_ids=${res.id}`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async pauseCampaign(id: string): Promise<void> {
|
|
279
|
+
await this.rateLimiter.acquire();
|
|
280
|
+
await this.apiCall('POST', `/${id}`, undefined, { status: 'PAUSED' });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async resumeCampaign(id: string): Promise<void> {
|
|
284
|
+
await this.rateLimiter.acquire();
|
|
285
|
+
await this.apiCall('POST', `/${id}`, undefined, { status: 'ACTIVE' });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async deleteCampaign(id: string): Promise<void> {
|
|
289
|
+
await this.rateLimiter.acquire();
|
|
290
|
+
await this.apiCall('DELETE', `/${id}`, undefined, {});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async updateCampaign(id: string, changes: CampaignUpdate): Promise<void> {
|
|
294
|
+
await this.rateLimiter.acquire();
|
|
295
|
+
const params: Record<string, unknown> = {};
|
|
296
|
+
if (changes.name) params.name = changes.name;
|
|
297
|
+
// Budget changes go through ad set, not campaign on Meta
|
|
298
|
+
await this.apiCall('POST', `/${id}`, undefined, params);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async updateBudget(id: string, dailyBudget: Cents): Promise<void> {
|
|
302
|
+
await this.rateLimiter.acquire();
|
|
303
|
+
// Meta budgets are in the account's currency smallest unit (cents for USD)
|
|
304
|
+
await this.apiCall('POST', `/${id}`, undefined, { daily_budget: dailyBudget });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async updateCreative(id: string, creative: CreativeConfig): Promise<void> {
|
|
308
|
+
await this.rateLimiter.acquire();
|
|
309
|
+
// Creative updates go through the ad object, not campaign
|
|
310
|
+
throw new Error('Creative update requires ad-level API call — implement per ad structure');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async getSpend(dateRange: { start: string; end: string }): Promise<SpendReport> {
|
|
314
|
+
await this.rateLimiter.acquire();
|
|
315
|
+
const res = await this.apiCall('GET', `/act_${this.adAccountId}/insights`, undefined, {
|
|
316
|
+
fields: 'campaign_id,spend,impressions,clicks,conversions',
|
|
317
|
+
time_range: JSON.stringify({ since: dateRange.start, until: dateRange.end }),
|
|
318
|
+
level: 'campaign',
|
|
319
|
+
});
|
|
320
|
+
const resData = res.data as Array<Record<string, string>>;
|
|
321
|
+
return {
|
|
322
|
+
platform: 'meta',
|
|
323
|
+
dateRange,
|
|
324
|
+
totalSpend: toCents(resData.reduce((sum: number, r: Record<string, string>) => sum + parseFloat(r.spend), 0)),
|
|
325
|
+
campaigns: resData.map((r: Record<string, string>) => ({
|
|
326
|
+
externalId: r.campaign_id,
|
|
327
|
+
spend: toCents(parseFloat(r.spend)),
|
|
328
|
+
impressions: parseInt(r.impressions),
|
|
329
|
+
clicks: parseInt(r.clicks),
|
|
330
|
+
conversions: parseInt(r.conversions || '0'),
|
|
331
|
+
})),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async getPerformance(campaignId: string): Promise<PerformanceMetrics> {
|
|
336
|
+
await this.rateLimiter.acquire();
|
|
337
|
+
const res = await this.apiCall('GET', `/${campaignId}/insights`, undefined, {
|
|
338
|
+
fields: 'impressions,clicks,conversions,spend,ctr,cpc',
|
|
339
|
+
});
|
|
340
|
+
const d = (res.data as Array<Record<string, string>>)[0];
|
|
341
|
+
const spend = toCents(parseFloat(d.spend));
|
|
342
|
+
const conversions = parseInt(d.conversions || '0');
|
|
343
|
+
const revenue = 0 as Cents; // Revenue comes from Stripe, not the ad platform
|
|
344
|
+
return {
|
|
345
|
+
campaignId,
|
|
346
|
+
impressions: parseInt(d.impressions),
|
|
347
|
+
clicks: parseInt(d.clicks),
|
|
348
|
+
conversions,
|
|
349
|
+
spend,
|
|
350
|
+
ctr: parseFloat(d.ctr) as Percentage,
|
|
351
|
+
cpc: toCents(parseFloat(d.cpc)),
|
|
352
|
+
roas: (revenue > 0 ? toDollars(revenue) / toDollars(spend) : 0) as Ratio,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async getInsights(campaignId: string, metrics: string[]): Promise<InsightData> {
|
|
357
|
+
await this.rateLimiter.acquire();
|
|
358
|
+
const res = await this.apiCall('GET', `/${campaignId}/insights`, undefined, {
|
|
359
|
+
fields: metrics.join(','),
|
|
360
|
+
});
|
|
361
|
+
return { campaignId, metrics: (res.data as Array<Record<string, number>>)[0] ?? {} };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Helpers ──────────────────────
|
|
365
|
+
|
|
366
|
+
private mapObjective(obj: CampaignConfig['objective']): string {
|
|
367
|
+
const map = { awareness: 'OUTCOME_AWARENESS', traffic: 'OUTCOME_TRAFFIC', conversions: 'OUTCOME_SALES' };
|
|
368
|
+
return map[obj];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private async apiCall(
|
|
372
|
+
method: string, path: string, tokens: OAuthTokens | undefined, params: Record<string, unknown>
|
|
373
|
+
): Promise<Record<string, unknown>> {
|
|
374
|
+
// Sanitize platform response data per §9.19.16
|
|
375
|
+
// All string fields: strip HTML tags, escape <>&"', truncate to 500 chars
|
|
376
|
+
// Use idempotency key for POST/DELETE per ADR-3
|
|
377
|
+
throw new Error('HTTP implementation — use node:https, no SDK dependencies');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Token Bucket Rate Limiter ─────────────────────────
|
|
382
|
+
|
|
383
|
+
class TokenBucketLimiter {
|
|
384
|
+
private tokens: number;
|
|
385
|
+
private lastRefill: number;
|
|
386
|
+
private readonly capacity: number;
|
|
387
|
+
private readonly refillRate: number; // tokens per second
|
|
388
|
+
|
|
389
|
+
constructor(opts: { capacity: number; refillRate: number }) {
|
|
390
|
+
this.capacity = opts.capacity;
|
|
391
|
+
this.refillRate = opts.refillRate;
|
|
392
|
+
this.tokens = opts.capacity;
|
|
393
|
+
this.lastRefill = Date.now();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async acquire(): Promise<void> {
|
|
397
|
+
this.refill();
|
|
398
|
+
if (this.tokens < 1) {
|
|
399
|
+
const waitMs = (1 / this.refillRate) * 1000;
|
|
400
|
+
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
401
|
+
this.refill();
|
|
402
|
+
}
|
|
403
|
+
this.tokens -= 1;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private refill(): void {
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
const elapsed = (now - this.lastRefill) / 1000;
|
|
409
|
+
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
|
|
410
|
+
this.lastRefill = now;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export type {
|
|
415
|
+
AdPlatformSetup, AdPlatformAdapter, ReadOnlyAdapter,
|
|
416
|
+
CampaignConfig, CampaignResult, CampaignUpdate, CreativeConfig,
|
|
417
|
+
SpendReport, PerformanceMetrics, InsightData,
|
|
418
|
+
OAuthTokens, ConnectionStatus, PlatformError,
|
|
419
|
+
Cents, Percentage, Ratio, AdPlatform,
|
|
420
|
+
};
|
|
421
|
+
export { toCents, toDollars, MetaAdapter, TokenBucketLimiter };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: AI Classifier
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Every classification has a confidence score — never act on low confidence blindly
|
|
6
|
+
* - Confidence-based routing: high → auto-act, medium → soft-act, low → human review
|
|
7
|
+
* - Fallback chain: primary model → fallback model → rule-based → human queue
|
|
8
|
+
* - Log every classification for eval and drift detection
|
|
9
|
+
* - Never expose raw model confidence to users — map to business thresholds
|
|
10
|
+
*
|
|
11
|
+
* Agents: Picard (routing architecture), Stark (service layer), Batman (edge cases)
|
|
12
|
+
*
|
|
13
|
+
* Provider note: Primary examples use Anthropic SDK. OpenAI adaptation noted inline.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
|
|
19
|
+
// --- Core types ---
|
|
20
|
+
|
|
21
|
+
export interface ClassificationResult<T extends string> {
|
|
22
|
+
label: T
|
|
23
|
+
confidence: number // 0.0 - 1.0
|
|
24
|
+
reasoning: string // Model's explanation — logged, not shown to users
|
|
25
|
+
model: string // Which model produced this result
|
|
26
|
+
latencyMs: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Thresholds are business decisions, not model tuning knobs.
|
|
30
|
+
// Adjust based on eval results (see ai-eval.ts), not gut feeling.
|
|
31
|
+
export const CONFIDENCE_THRESHOLD = {
|
|
32
|
+
AUTO_ACT: 0.85, // High confidence — act without human review
|
|
33
|
+
SOFT_ACT: 0.60, // Medium — act but flag for async review
|
|
34
|
+
REJECT: 0.60, // Below this — route to human queue
|
|
35
|
+
} as const
|
|
36
|
+
|
|
37
|
+
// --- Single-label classifier ---
|
|
38
|
+
|
|
39
|
+
const ClassifierOutputSchema = z.object({
|
|
40
|
+
label: z.string(),
|
|
41
|
+
confidence: z.number().min(0).max(1),
|
|
42
|
+
reasoning: z.string(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export async function classify<T extends string>(
|
|
46
|
+
client: Anthropic,
|
|
47
|
+
text: string,
|
|
48
|
+
labels: readonly T[],
|
|
49
|
+
systemPrompt: string
|
|
50
|
+
): Promise<ClassificationResult<T>> {
|
|
51
|
+
const start = Date.now()
|
|
52
|
+
|
|
53
|
+
const response = await client.messages.create({
|
|
54
|
+
model: 'claude-sonnet-4-20250514',
|
|
55
|
+
max_tokens: 256,
|
|
56
|
+
system: [
|
|
57
|
+
systemPrompt,
|
|
58
|
+
`Valid labels: ${labels.join(', ')}`,
|
|
59
|
+
'Respond with JSON: { "label": "<label>", "confidence": <0.0-1.0>, "reasoning": "<why>" }',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
messages: [{ role: 'user', content: text }],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const content = response.content[0]
|
|
65
|
+
if (content.type !== 'text') throw new Error('Expected text response')
|
|
66
|
+
|
|
67
|
+
const parsed = ClassifierOutputSchema.parse(JSON.parse(content.text))
|
|
68
|
+
|
|
69
|
+
// Validate label is one of the allowed values
|
|
70
|
+
if (!labels.includes(parsed.label as T)) {
|
|
71
|
+
throw new Error(`Model returned invalid label: ${parsed.label}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
label: parsed.label as T,
|
|
76
|
+
confidence: parsed.confidence,
|
|
77
|
+
reasoning: parsed.reasoning,
|
|
78
|
+
model: 'claude-sonnet-4-20250514',
|
|
79
|
+
latencyMs: Date.now() - start,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// OpenAI adaptation:
|
|
84
|
+
// const response = await openai.chat.completions.create({
|
|
85
|
+
// model: 'gpt-4o-mini', // Cheaper model for classification
|
|
86
|
+
// messages: [...],
|
|
87
|
+
// response_format: { type: 'json_object' },
|
|
88
|
+
// })
|
|
89
|
+
// Parse response.choices[0].message.content the same way.
|
|
90
|
+
|
|
91
|
+
// --- Confidence-based routing ---
|
|
92
|
+
|
|
93
|
+
type RouteAction = 'auto_act' | 'soft_act' | 'human_review'
|
|
94
|
+
|
|
95
|
+
export function routeByConfidence<T extends string>(
|
|
96
|
+
result: ClassificationResult<T>
|
|
97
|
+
): RouteAction {
|
|
98
|
+
if (result.confidence >= CONFIDENCE_THRESHOLD.AUTO_ACT) return 'auto_act'
|
|
99
|
+
if (result.confidence >= CONFIDENCE_THRESHOLD.SOFT_ACT) return 'soft_act'
|
|
100
|
+
return 'human_review'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Usage:
|
|
104
|
+
// const result = await classify(client, ticket.body, ['billing', 'technical', 'general'], prompt)
|
|
105
|
+
// const action = routeByConfidence(result)
|
|
106
|
+
// if (action === 'auto_act') await autoRoute(ticket, result.label)
|
|
107
|
+
// else if (action === 'soft_act') await autoRoute(ticket, result.label, { flagForReview: true })
|
|
108
|
+
// else await queueForHuman(ticket, result)
|
|
109
|
+
|
|
110
|
+
// --- Fallback chain ---
|
|
111
|
+
|
|
112
|
+
interface ClassifierProvider<T extends string> {
|
|
113
|
+
name: string
|
|
114
|
+
classify: (text: string, labels: readonly T[]) => Promise<ClassificationResult<T>>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Try classifiers in order. First one to return high-confidence wins.
|
|
119
|
+
* If all fail or return low confidence, returns the best result with 'human_review' routing.
|
|
120
|
+
*/
|
|
121
|
+
export async function classifyWithFallback<T extends string>(
|
|
122
|
+
text: string,
|
|
123
|
+
labels: readonly T[],
|
|
124
|
+
providers: ClassifierProvider<T>[]
|
|
125
|
+
): Promise<ClassificationResult<T> & { action: RouteAction }> {
|
|
126
|
+
let bestResult: ClassificationResult<T> | null = null
|
|
127
|
+
|
|
128
|
+
for (const provider of providers) {
|
|
129
|
+
try {
|
|
130
|
+
const result = await provider.classify(text, labels)
|
|
131
|
+
|
|
132
|
+
// Keep track of best result across all providers
|
|
133
|
+
if (!bestResult || result.confidence > bestResult.confidence) {
|
|
134
|
+
bestResult = result
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// If confidence is high enough to act, stop the chain
|
|
138
|
+
if (result.confidence >= CONFIDENCE_THRESHOLD.SOFT_ACT) {
|
|
139
|
+
return { ...result, action: routeByConfidence(result) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Low confidence — try next provider in chain
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Provider failed — log and try next
|
|
145
|
+
console.error(`[Classifier] ${provider.name} failed:`, {
|
|
146
|
+
error: error instanceof Error ? error.message : 'Unknown',
|
|
147
|
+
text: text.slice(0, 100), // Truncate for logging — never log full PII
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// All providers exhausted — return best result or default to human review
|
|
153
|
+
if (bestResult) {
|
|
154
|
+
return { ...bestResult, action: 'human_review' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Complete failure — nothing classified, must go to human
|
|
158
|
+
return {
|
|
159
|
+
label: labels[0], // Default label — human will override
|
|
160
|
+
confidence: 0,
|
|
161
|
+
reasoning: 'All classifier providers failed',
|
|
162
|
+
model: 'fallback',
|
|
163
|
+
latencyMs: 0,
|
|
164
|
+
action: 'human_review',
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Example fallback chain setup:
|
|
169
|
+
// const providers: ClassifierProvider<TicketCategory>[] = [
|
|
170
|
+
// { name: 'anthropic', classify: (text, labels) => classify(anthropicClient, text, labels, prompt) },
|
|
171
|
+
// { name: 'openai', classify: (text, labels) => classifyOpenAI(openaiClient, text, labels) },
|
|
172
|
+
// { name: 'rules', classify: (text, labels) => ruleBasedClassifier(text, labels) },
|
|
173
|
+
// ]
|
|
174
|
+
// const result = await classifyWithFallback(ticket.body, categories, providers)
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Framework adaptations:
|
|
178
|
+
*
|
|
179
|
+
* Express:
|
|
180
|
+
* - Classification endpoint: POST /api/classify with Zod validation on input
|
|
181
|
+
* - Confidence thresholds in environment config, not hardcoded
|
|
182
|
+
* - Log classifications to structured logging (Winston/Pino) for eval pipelines
|
|
183
|
+
*
|
|
184
|
+
* FastAPI:
|
|
185
|
+
* - classify() → async def classify() with Pydantic ClassificationResult model
|
|
186
|
+
* - Fallback chain: same pattern, use httpx.AsyncClient for provider calls
|
|
187
|
+
* - Route confidence thresholds via Settings (pydantic-settings)
|
|
188
|
+
* - Background classification: FastAPI BackgroundTasks for non-blocking
|
|
189
|
+
*
|
|
190
|
+
* Django:
|
|
191
|
+
* - Classification logic in services.py — never in views
|
|
192
|
+
* - Store ClassificationResult in a ClassificationLog model for audit trail
|
|
193
|
+
* - Thresholds in django.conf.settings or per-tenant config
|
|
194
|
+
* - Async classification: Celery task with result stored in DB
|
|
195
|
+
*/
|