@viccydev/pi-fpa 0.2.0
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/README.md +135 -0
- package/extensions/fpa-data/calc.ts +544 -0
- package/extensions/fpa-data/index.ts +478 -0
- package/extensions/fpa-data/registry.ts +303 -0
- package/extensions/fpa-data/sql.ts +412 -0
- package/extensions/fpa-data/supabase.ts +96 -0
- package/package.json +54 -0
- package/prompts/fpa-plan-cycle.md +44 -0
- package/prompts/fpa-review-cycle.md +33 -0
- package/skills/fpa-analyze-drivers/SKILL.md +30 -0
- package/skills/fpa-analyze-drivers/references/artifact-contract.md +34 -0
- package/skills/fpa-apply-core-rules/SKILL.md +34 -0
- package/skills/fpa-apply-core-rules/references/core-rules.md +96 -0
- package/skills/fpa-diagnose-actuals/SKILL.md +30 -0
- package/skills/fpa-diagnose-actuals/references/artifact-contract.md +38 -0
- package/skills/fpa-execute-approved-strategy/SKILL.md +40 -0
- package/skills/fpa-execute-approved-strategy/references/artifact-contract.md +29 -0
- package/skills/fpa-forecast-approved-strategy/SKILL.md +39 -0
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +36 -0
- package/skills/fpa-plan-cycle/SKILL.md +29 -0
- package/skills/fpa-plan-cycle/references/artifact-contract.md +41 -0
- package/skills/fpa-recommend-strategy/SKILL.md +29 -0
- package/skills/fpa-recommend-strategy/references/artifact-contract.md +30 -0
- package/skills/fpa-review-cycle/SKILL.md +32 -0
- package/skills/fpa-review-cycle/references/artifact-contract.md +30 -0
- package/skills/fpa-review-strategy/SKILL.md +28 -0
- package/skills/fpa-review-strategy/references/artifact-contract.md +25 -0
- package/skills/fpa-simulate-strategies/SKILL.md +32 -0
- package/skills/fpa-simulate-strategies/references/artifact-contract.md +35 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only SQL builder for the FP&A Supabase mart.
|
|
3
|
+
*
|
|
4
|
+
* The builder only emits SELECT statements over datasets, dimensions, and
|
|
5
|
+
* measures declared in registry.ts. Identifiers are validated against the
|
|
6
|
+
* registry and literal values are escaped, so the LLM can never inject SQL.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type DatasetDef,
|
|
11
|
+
type DerivedDef,
|
|
12
|
+
getDataset,
|
|
13
|
+
} from "./registry.ts";
|
|
14
|
+
|
|
15
|
+
export type TimeGrain = "day" | "week" | "month";
|
|
16
|
+
export type CohortBucket = "day" | "week" | "month" | "total";
|
|
17
|
+
|
|
18
|
+
export interface QuerySpec {
|
|
19
|
+
dataset: string;
|
|
20
|
+
metrics: string[];
|
|
21
|
+
dimensions?: string[];
|
|
22
|
+
timeGrain?: TimeGrain;
|
|
23
|
+
dateFrom?: string;
|
|
24
|
+
dateTo?: string;
|
|
25
|
+
filters?: Record<string, string | number | Array<string | number>>;
|
|
26
|
+
sort?: { by: string; direction?: "asc" | "desc" };
|
|
27
|
+
limit?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BuiltQuery {
|
|
31
|
+
sql: string;
|
|
32
|
+
dataset: DatasetDef;
|
|
33
|
+
measures: string[];
|
|
34
|
+
derived: DerivedDef[];
|
|
35
|
+
dimensions: string[];
|
|
36
|
+
timeGrain?: TimeGrain;
|
|
37
|
+
notes: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_LIMIT = 200;
|
|
41
|
+
export const MAX_LIMIT = 1000;
|
|
42
|
+
|
|
43
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
44
|
+
const SORT_DIRECTIONS = new Set(["asc", "desc"]);
|
|
45
|
+
|
|
46
|
+
export function escapeLiteral(value: string | number): string {
|
|
47
|
+
if (typeof value === "number") {
|
|
48
|
+
if (!Number.isFinite(value)) {
|
|
49
|
+
throw new Error("Filter values must be finite numbers or strings.");
|
|
50
|
+
}
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function assertDate(value: string, label: string): string {
|
|
57
|
+
if (!DATE_RE.test(value)) {
|
|
58
|
+
throw new Error(`${label} must be an ISO date (YYYY-MM-DD), got "${value}".`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function assertReadOnly(sql: string): string {
|
|
64
|
+
if (!/^\s*(select|with)\b/i.test(sql)) {
|
|
65
|
+
throw new Error("Internal error: generated SQL is not a SELECT statement.");
|
|
66
|
+
}
|
|
67
|
+
return sql;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeFilterValues(
|
|
71
|
+
value: string | number | Array<string | number>,
|
|
72
|
+
): Array<string | number> {
|
|
73
|
+
const values = Array.isArray(value) ? value : [value];
|
|
74
|
+
if (values.length === 0) {
|
|
75
|
+
throw new Error("Filter arrays must contain at least one value.");
|
|
76
|
+
}
|
|
77
|
+
return values;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ResolvedDataset {
|
|
81
|
+
dataset: DatasetDef;
|
|
82
|
+
measures: string[];
|
|
83
|
+
derived: DerivedDef[];
|
|
84
|
+
notes: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveMetrics(dataset: DatasetDef, metrics: string[], useEventName: boolean): ResolvedDataset {
|
|
88
|
+
if (metrics.length === 0) {
|
|
89
|
+
throw new Error("At least one metric is required.");
|
|
90
|
+
}
|
|
91
|
+
const notes: string[] = [];
|
|
92
|
+
const baseNames = new Set(dataset.measures.map((m) => m.name));
|
|
93
|
+
if (useEventName && !dataset.eventName) {
|
|
94
|
+
throw new Error(`Dataset "${dataset.id}" does not support the event_name dimension.`);
|
|
95
|
+
}
|
|
96
|
+
const pool =
|
|
97
|
+
useEventName && dataset.eventName
|
|
98
|
+
? new Set(Object.keys(dataset.eventName.measures))
|
|
99
|
+
: baseNames;
|
|
100
|
+
const derivedByName = new Map(
|
|
101
|
+
dataset.derived
|
|
102
|
+
.filter((d) => pool.has(d.numerator) && pool.has(d.denominator))
|
|
103
|
+
.map((d) => [d.name, d]),
|
|
104
|
+
);
|
|
105
|
+
const selected = new Set<string>();
|
|
106
|
+
const derived: DerivedDef[] = [];
|
|
107
|
+
|
|
108
|
+
for (const metric of metrics) {
|
|
109
|
+
if (pool.has(metric)) {
|
|
110
|
+
selected.add(metric);
|
|
111
|
+
} else if (derivedByName.has(metric)) {
|
|
112
|
+
const def = derivedByName.get(metric) as DerivedDef;
|
|
113
|
+
derived.push(def);
|
|
114
|
+
selected.add(def.numerator);
|
|
115
|
+
selected.add(def.denominator);
|
|
116
|
+
} else if (useEventName && (baseNames.has(metric) || dataset.derived.some((d) => d.name === metric))) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Metric "${metric}" is not available with the event_name dimension on "${dataset.id}". ` +
|
|
119
|
+
`Allowed: ${[...pool, ...derivedByName.keys()].join(", ")}.`,
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
const available = [...pool, ...derivedByName.keys()].join(", ");
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Unknown metric "${metric}" for dataset "${dataset.id}". Available metrics: ${available}.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (useEventName && dataset.eventName) {
|
|
130
|
+
notes.push(`Metrics are read from the per-event breakdown in ${dataset.eventName.jsonbColumn}.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { dataset, measures: [...selected], derived, notes };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function measureSql(dataset: DatasetDef, name: string, useEventName: boolean): string {
|
|
137
|
+
if (useEventName && dataset.eventName) {
|
|
138
|
+
return dataset.eventName.measures[name];
|
|
139
|
+
}
|
|
140
|
+
const def = dataset.measures.find((m) => m.name === name);
|
|
141
|
+
if (!def) throw new Error(`Internal error: unresolved measure "${name}".`);
|
|
142
|
+
return def.sql;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveBreakdown(
|
|
146
|
+
dataset: DatasetDef,
|
|
147
|
+
dimensionNames: string[],
|
|
148
|
+
filterKeys: string[],
|
|
149
|
+
): { predicate?: string; note?: string } {
|
|
150
|
+
if (!dataset.breakdown) return {};
|
|
151
|
+
const used = new Set([...dimensionNames, ...filterKeys]);
|
|
152
|
+
const matches = Object.entries(dataset.breakdown.byDimension).filter(([dim]) => used.has(dim));
|
|
153
|
+
if (matches.length > 1) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Dimensions ${matches.map(([d]) => d).join(" and ")} cannot be combined: ` +
|
|
156
|
+
"the source table has no breakdown that carries both.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const value = matches.length === 1 ? matches[0][1] : dataset.breakdown.base;
|
|
160
|
+
return {
|
|
161
|
+
predicate: `${dataset.breakdown.column} = ${escapeLiteral(value)}`,
|
|
162
|
+
note: `Pinned ${dataset.breakdown.column} = ${value} to avoid double counting.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function buildQuery(spec: QuerySpec): BuiltQuery {
|
|
167
|
+
const dataset = getDataset(spec.dataset);
|
|
168
|
+
const dimensionNames = spec.dimensions ?? [];
|
|
169
|
+
const useEventName =
|
|
170
|
+
dimensionNames.includes("event_name") || "event_name" in (spec.filters ?? {});
|
|
171
|
+
|
|
172
|
+
const dimensionDefs = dimensionNames.map((name) => {
|
|
173
|
+
const def = dataset.dimensions.find((d) => d.name === name);
|
|
174
|
+
if (!def) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Unknown dimension "${name}" for dataset "${dataset.id}". ` +
|
|
177
|
+
`Available: ${dataset.dimensions.map((d) => d.name).join(", ")}.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return def;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const resolved = resolveMetrics(dataset, spec.metrics, useEventName);
|
|
184
|
+
const notes = [...resolved.notes];
|
|
185
|
+
|
|
186
|
+
const where: string[] = [];
|
|
187
|
+
if (spec.dateFrom) where.push(`${dataset.dateColumn} >= ${escapeLiteral(assertDate(spec.dateFrom, "dateFrom"))}`);
|
|
188
|
+
if (spec.dateTo) where.push(`${dataset.dateColumn} <= ${escapeLiteral(assertDate(spec.dateTo, "dateTo"))}`);
|
|
189
|
+
|
|
190
|
+
const filterKeys: string[] = [];
|
|
191
|
+
for (const [key, raw] of Object.entries(spec.filters ?? {})) {
|
|
192
|
+
const def = dataset.dimensions.find((d) => d.name === key);
|
|
193
|
+
if (!def) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Unknown filter field "${key}" for dataset "${dataset.id}". ` +
|
|
196
|
+
`Filterable fields: ${dataset.dimensions.map((d) => d.name).join(", ")}. ` +
|
|
197
|
+
"Use dateFrom/dateTo for the date range.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
filterKeys.push(key);
|
|
201
|
+
const values = normalizeFilterValues(raw).map(escapeLiteral);
|
|
202
|
+
where.push(values.length === 1 ? `${def.sql} = ${values[0]}` : `${def.sql} in (${values.join(", ")})`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const breakdown = resolveBreakdown(dataset, dimensionNames, filterKeys);
|
|
206
|
+
if (breakdown.predicate) {
|
|
207
|
+
where.push(breakdown.predicate);
|
|
208
|
+
if (breakdown.note) notes.push(breakdown.note);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const selectParts: string[] = [];
|
|
212
|
+
const groupParts: string[] = [];
|
|
213
|
+
if (spec.timeGrain) {
|
|
214
|
+
selectParts.push(`date_trunc('${spec.timeGrain}', ${dataset.dateColumn})::date as period`);
|
|
215
|
+
groupParts.push("1");
|
|
216
|
+
}
|
|
217
|
+
for (const def of dimensionDefs) {
|
|
218
|
+
selectParts.push(`${def.sql} as ${def.name}`);
|
|
219
|
+
groupParts.push(String(selectParts.length));
|
|
220
|
+
}
|
|
221
|
+
for (const name of resolved.measures) {
|
|
222
|
+
selectParts.push(`${measureSql(dataset, name, useEventName)} as ${name}`);
|
|
223
|
+
}
|
|
224
|
+
selectParts.push(`min(${dataset.dateColumn})::text as date_min`);
|
|
225
|
+
selectParts.push(`max(${dataset.dateColumn})::text as date_max`);
|
|
226
|
+
selectParts.push("count(*) as source_rows");
|
|
227
|
+
|
|
228
|
+
let from = dataset.table;
|
|
229
|
+
if (useEventName && dataset.eventName) {
|
|
230
|
+
from += ` cross join lateral jsonb_each(${dataset.eventName.jsonbColumn}) as e(k, v)`;
|
|
231
|
+
} else if (dataset.lateral) {
|
|
232
|
+
from += ` ${dataset.lateral}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const sortable = new Set([
|
|
236
|
+
...(spec.timeGrain ? ["period"] : []),
|
|
237
|
+
...dimensionNames,
|
|
238
|
+
...resolved.measures,
|
|
239
|
+
]);
|
|
240
|
+
let orderBy: string;
|
|
241
|
+
if (spec.sort) {
|
|
242
|
+
if (!sortable.has(spec.sort.by)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`sort.by must be one of the selected columns: ${[...sortable].join(", ")}. ` +
|
|
245
|
+
"Derived metrics are computed after SQL and cannot be sorted server-side.",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const direction = spec.sort.direction ?? "desc";
|
|
249
|
+
if (!SORT_DIRECTIONS.has(direction)) {
|
|
250
|
+
throw new Error(`sort.direction must be "asc" or "desc".`);
|
|
251
|
+
}
|
|
252
|
+
orderBy = `${spec.sort.by} ${direction}`;
|
|
253
|
+
} else if (spec.timeGrain) {
|
|
254
|
+
orderBy = "period asc";
|
|
255
|
+
} else if (resolved.measures.length > 0) {
|
|
256
|
+
orderBy = `${resolved.measures[0]} desc`;
|
|
257
|
+
} else {
|
|
258
|
+
orderBy = "1";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const limit = Math.max(1, Math.min(spec.limit ?? DEFAULT_LIMIT, MAX_LIMIT));
|
|
262
|
+
if ((spec.limit ?? DEFAULT_LIMIT) > MAX_LIMIT) {
|
|
263
|
+
notes.push(`limit was capped at ${MAX_LIMIT}.`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const sql = [
|
|
267
|
+
`select ${selectParts.join(", ")}`,
|
|
268
|
+
`from ${from}`,
|
|
269
|
+
where.length > 0 ? `where ${where.join(" and ")}` : "",
|
|
270
|
+
groupParts.length > 0 ? `group by ${groupParts.join(", ")}` : "",
|
|
271
|
+
`order by ${orderBy}`,
|
|
272
|
+
`limit ${limit}`,
|
|
273
|
+
]
|
|
274
|
+
.filter(Boolean)
|
|
275
|
+
.join(" ");
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
sql: assertReadOnly(sql),
|
|
279
|
+
dataset,
|
|
280
|
+
measures: resolved.measures,
|
|
281
|
+
derived: resolved.derived,
|
|
282
|
+
dimensions: dimensionNames,
|
|
283
|
+
timeGrain: spec.timeGrain,
|
|
284
|
+
notes,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface CohortSpec {
|
|
289
|
+
appCodes?: string[];
|
|
290
|
+
platforms?: string[];
|
|
291
|
+
installFrom: string;
|
|
292
|
+
installTo: string;
|
|
293
|
+
horizons?: number[];
|
|
294
|
+
bucket?: CohortBucket;
|
|
295
|
+
/** Extra grouping keys kept in the output. Defaults to app_code + platform. */
|
|
296
|
+
groupBy?: Array<"app_code" | "platform">;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface BuiltCohortQuery {
|
|
300
|
+
sql: string;
|
|
301
|
+
spendSql: string;
|
|
302
|
+
asOfSql: string;
|
|
303
|
+
horizons: number[];
|
|
304
|
+
bucket: CohortBucket;
|
|
305
|
+
groupBy: Array<"app_code" | "platform">;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const DEFAULT_HORIZONS = [0, 3, 7, 14, 30];
|
|
309
|
+
export const MAX_HORIZON = 365;
|
|
310
|
+
|
|
311
|
+
function bucketExpr(bucket: CohortBucket, column: string): string {
|
|
312
|
+
if (bucket === "total") return "'total'";
|
|
313
|
+
return `date_trunc('${bucket}', ${column})::date::text`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function buildCohortQuery(spec: CohortSpec): BuiltCohortQuery {
|
|
317
|
+
const installFrom = assertDate(spec.installFrom, "installFrom");
|
|
318
|
+
const installTo = assertDate(spec.installTo, "installTo");
|
|
319
|
+
const bucket: CohortBucket = spec.bucket ?? "week";
|
|
320
|
+
const groupBy = spec.groupBy ?? ["app_code", "platform"];
|
|
321
|
+
for (const key of groupBy) {
|
|
322
|
+
if (key !== "app_code" && key !== "platform") {
|
|
323
|
+
throw new Error(`groupBy supports only app_code and platform, got "${key}".`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const horizons = [...new Set(spec.horizons ?? DEFAULT_HORIZONS)].sort((a, b) => a - b);
|
|
328
|
+
if (horizons.length === 0) {
|
|
329
|
+
throw new Error("At least one horizon is required.");
|
|
330
|
+
}
|
|
331
|
+
for (const n of horizons) {
|
|
332
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_HORIZON) {
|
|
333
|
+
throw new Error(`Horizons must be integers between 0 and ${MAX_HORIZON}, got ${n}.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const scope: string[] = [
|
|
338
|
+
`install_date >= ${escapeLiteral(installFrom)}`,
|
|
339
|
+
`install_date <= ${escapeLiteral(installTo)}`,
|
|
340
|
+
];
|
|
341
|
+
if (spec.appCodes && spec.appCodes.length > 0) {
|
|
342
|
+
scope.push(`app_code in (${spec.appCodes.map(escapeLiteral).join(", ")})`);
|
|
343
|
+
}
|
|
344
|
+
if (spec.platforms && spec.platforms.length > 0) {
|
|
345
|
+
scope.push(`platform in (${spec.platforms.map(escapeLiteral).join(", ")})`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const perInstallMeasures = horizons
|
|
349
|
+
.flatMap((n) => [
|
|
350
|
+
`sum(net_proceeds_usd) filter (where event_date <= install_date + ${n}) as net_d${n}`,
|
|
351
|
+
`max(active_user_count) filter (where event_date = install_date + ${n}) as active_d${n}`,
|
|
352
|
+
])
|
|
353
|
+
.join(", ");
|
|
354
|
+
|
|
355
|
+
const outerKeys = ["bucket", ...groupBy];
|
|
356
|
+
const outerMeasures = horizons
|
|
357
|
+
.flatMap((n) => [`sum(net_d${n}) as net_d${n}`, `sum(active_d${n}) as active_d${n}`])
|
|
358
|
+
.join(", ");
|
|
359
|
+
|
|
360
|
+
const sql = assertReadOnly(
|
|
361
|
+
`with per_install as (` +
|
|
362
|
+
`select app_code, platform, install_date, max(installed_user_count) as cohort_size, ${perInstallMeasures} ` +
|
|
363
|
+
`from appsflyer_install_cohort_daily where ${scope.join(" and ")} group by 1, 2, 3` +
|
|
364
|
+
`) select ${bucketExpr(bucket, "install_date")} as bucket` +
|
|
365
|
+
(groupBy.length > 0 ? `, ${groupBy.join(", ")}` : "") +
|
|
366
|
+
`, count(*) as install_days` +
|
|
367
|
+
`, count(*) filter (where cohort_size is null) as days_missing_cohort_size` +
|
|
368
|
+
`, sum(cohort_size) as cohort_size` +
|
|
369
|
+
`, min(install_date)::text as install_min, max(install_date)::text as install_max` +
|
|
370
|
+
`, ${outerMeasures} ` +
|
|
371
|
+
`from per_install group by ${outerKeys.map((_, i) => i + 1).join(", ")} ` +
|
|
372
|
+
`order by ${outerKeys.map((_, i) => i + 1).join(", ")}`,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const spendSql = assertReadOnly(
|
|
376
|
+
`select ${bucketExpr(bucket, "install_date")} as bucket` +
|
|
377
|
+
(groupBy.length > 0 ? `, ${groupBy.join(", ")}` : "") +
|
|
378
|
+
`, sum(cost_usd) as spend, sum(installs) as ua_installs` +
|
|
379
|
+
`, count(distinct install_date) as spend_days ` +
|
|
380
|
+
`from appsflyer_ua_campaign_daily ` +
|
|
381
|
+
`where breakdown_type = 'CAMPAIGN' and ${scope.join(" and ")} ` +
|
|
382
|
+
`group by ${outerKeys.map((_, i) => i + 1).join(", ")}`,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const asOfSql = assertReadOnly(
|
|
386
|
+
`select min(event_date)::text as min_event_date, max(event_date)::text as max_event_date, ` +
|
|
387
|
+
`max(updated_at)::text as updated_at from appsflyer_install_cohort_daily`,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
return { sql, spendSql, asOfSql, horizons, bucket, groupBy };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Coverage catalog query used by fpa_data_catalog. */
|
|
394
|
+
export function buildCoverageSql(): string {
|
|
395
|
+
const parts = [
|
|
396
|
+
`select 'ua_spend' as dataset, count(*) as row_count, min(install_date)::text as date_min, max(install_date)::text as date_max, count(distinct app_code) as apps, max(updated_at)::text as updated_at from appsflyer_ua_campaign_daily`,
|
|
397
|
+
`select 'revenue_activity', count(*), min(event_date)::text, max(event_date)::text, count(distinct app_code), max(updated_at)::text from appsflyer_install_cohort_daily`,
|
|
398
|
+
`select 'cohort (fpa_cohort)', count(*), min(install_date)::text, max(install_date)::text, count(distinct app_code), max(updated_at)::text from appsflyer_install_cohort_daily`,
|
|
399
|
+
`select 'apple_store', count(*), min(metric_date)::text, max(metric_date)::text, count(distinct adam_id), max(updated_at)::text from apple_app_daily`,
|
|
400
|
+
`select 'product_events', count(*), min(event_date)::text, max(event_date)::text, count(distinct app_code), max(updated_at)::text from appsflyer_event_daily`,
|
|
401
|
+
`select 'mixpanel_product', count(*), min(business_date)::text, max(business_date)::text, count(distinct app_code), max(updated_at)::text from mixpanel_app_daily`,
|
|
402
|
+
];
|
|
403
|
+
return assertReadOnly(parts.join(" union all "));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Cohort-size availability window, reported by fpa_data_catalog. */
|
|
407
|
+
export function buildCohortSizeCoverageSql(): string {
|
|
408
|
+
return assertReadOnly(
|
|
409
|
+
`select min(install_date)::text as cohort_size_from, max(install_date)::text as cohort_size_to ` +
|
|
410
|
+
`from appsflyer_install_cohort_daily where installed_user_count is not null`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal read-only client for the Supabase Management API query endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Credentials come from the environment; nothing is embedded in the package:
|
|
5
|
+
* SUPABASE_PROJECT_REF - project ref, e.g. abcdefghijklmnopqrst
|
|
6
|
+
* SUPABASE_ACCESS_TOKEN - personal access token (sbp_...)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { assertReadOnly } from "./sql.ts";
|
|
10
|
+
|
|
11
|
+
const API_BASE = "https://api.supabase.com";
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export interface SupabaseConfig {
|
|
16
|
+
projectRef: string;
|
|
17
|
+
accessToken: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveConfig(env: Record<string, string | undefined> = process.env): SupabaseConfig {
|
|
21
|
+
const projectRef = env.SUPABASE_PROJECT_REF?.trim();
|
|
22
|
+
const accessToken = env.SUPABASE_ACCESS_TOKEN?.trim();
|
|
23
|
+
if (!projectRef || !accessToken) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"Supabase credentials are not configured. Set SUPABASE_PROJECT_REF and " +
|
|
26
|
+
"SUPABASE_ACCESS_TOKEN in the environment before starting Pi. " +
|
|
27
|
+
"No query was sent.",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (!/^[a-z0-9]{16,32}$/.test(projectRef)) {
|
|
31
|
+
throw new Error("SUPABASE_PROJECT_REF does not look like a valid project ref.");
|
|
32
|
+
}
|
|
33
|
+
return { projectRef, accessToken };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SqlRow = Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
export async function runQuery(
|
|
39
|
+
sql: string,
|
|
40
|
+
options: { signal?: AbortSignal; timeoutMs?: number; config?: SupabaseConfig } = {},
|
|
41
|
+
): Promise<SqlRow[]> {
|
|
42
|
+
assertReadOnly(sql);
|
|
43
|
+
const config = options.config ?? resolveConfig();
|
|
44
|
+
const signals = [AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)];
|
|
45
|
+
if (options.signal) signals.push(options.signal);
|
|
46
|
+
|
|
47
|
+
let response: Response;
|
|
48
|
+
try {
|
|
49
|
+
response = await fetch(
|
|
50
|
+
`${API_BASE}/v1/projects/${config.projectRef}/database/query`,
|
|
51
|
+
{
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${config.accessToken}`,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({ query: sql, read_only: true }),
|
|
58
|
+
signal: AbortSignal.any(signals),
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
63
|
+
throw new Error("Supabase query timed out. Narrow the date range or filters.");
|
|
64
|
+
}
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Could not reach the Supabase Management API: ${error instanceof Error ? error.message : String(error)}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const body = await response.text();
|
|
71
|
+
if (new TextEncoder().encode(body).byteLength > MAX_RESPONSE_BYTES) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Supabase response exceeds ${MAX_RESPONSE_BYTES} bytes. Narrow the query with filters, a shorter date range, or a lower limit.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let payload: unknown;
|
|
78
|
+
try {
|
|
79
|
+
payload = body ? JSON.parse(body) : null;
|
|
80
|
+
} catch {
|
|
81
|
+
throw new Error(`Supabase returned non-JSON data (HTTP ${response.status}).`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const detail =
|
|
86
|
+
payload && typeof payload === "object" && "message" in payload
|
|
87
|
+
? String((payload as { message: unknown }).message)
|
|
88
|
+
: body.slice(0, 500);
|
|
89
|
+
throw new Error(`Supabase Management API error ${response.status}: ${detail}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!Array.isArray(payload)) {
|
|
93
|
+
throw new Error("Unexpected Supabase response shape: expected an array of rows.");
|
|
94
|
+
}
|
|
95
|
+
return payload as SqlRow[];
|
|
96
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@viccydev/pi-fpa",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"fpa"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/linyqh/pi-fpa.git"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"registry": "https://registry.npmjs.org"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"README.md",
|
|
21
|
+
"prompts",
|
|
22
|
+
"skills",
|
|
23
|
+
"extensions"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
27
|
+
"test:structure": "node tests/package-structure.test.mjs",
|
|
28
|
+
"test:unit": "node tests/extension-unit.test.mjs",
|
|
29
|
+
"test:loader": "node tests/pi-loader-smoke.mjs",
|
|
30
|
+
"test:live": "node tests/live-smoke.mjs",
|
|
31
|
+
"pack:check": "npm pack --dry-run"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-ai": "*",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
|
+
"typebox": "*"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@earendil-works/pi-ai": "0.84.1",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
41
|
+
"typebox": "1.3.7"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"prompts": [
|
|
45
|
+
"./prompts"
|
|
46
|
+
],
|
|
47
|
+
"skills": [
|
|
48
|
+
"./skills"
|
|
49
|
+
],
|
|
50
|
+
"extensions": [
|
|
51
|
+
"./extensions/fpa-data/index.ts"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: 启动完整 FP&A 策略规划,并在匹配版本获得外部人工批准后形成正式预测
|
|
3
|
+
argument-hint: "<project-root> <cycle-id> [instructions]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
为项目 `$1` 启动 FP&A 周期 `$2`。
|
|
7
|
+
|
|
8
|
+
补充要求:`${@:3}`
|
|
9
|
+
|
|
10
|
+
## 输入解析
|
|
11
|
+
|
|
12
|
+
- 把 `$1` 作为项目根;先确认它真实存在,不得猜测或替换成开发者机器上的其他目录。
|
|
13
|
+
- 默认从项目根解析 `LOCAL_FPA_MART_FIELD_CATALOG.md` 和 `LOCAL_FPA_MART_SCHEMA.sql`。如果补充要求提供其他字段目录、Schema、数据快照或 Artifact 路径,以显式路径为准。
|
|
14
|
+
- 明确周期的开始时间、结束时间、业务时区、报告币种、比较基线,以及 App、Store、Product Group、Channel Group 范围。不能从 `$2` 的名称暗自推断日期边界。
|
|
15
|
+
- 关键输入缺失时列出缺口并保持 `blocked`;不得编造字段、表、数据、批准或 Artifact。
|
|
16
|
+
|
|
17
|
+
## 能力路由
|
|
18
|
+
|
|
19
|
+
先读取可用 Skill 中的 `fpa-apply-core-rules`,再从 `fpa-plan-cycle` 开始。根据当前阶段依次按需读取:
|
|
20
|
+
|
|
21
|
+
1. `fpa-diagnose-actuals`
|
|
22
|
+
2. `fpa-analyze-drivers`
|
|
23
|
+
3. `fpa-simulate-strategies`
|
|
24
|
+
4. `fpa-recommend-strategy`
|
|
25
|
+
5. `fpa-review-strategy`
|
|
26
|
+
6. `fpa-forecast-approved-strategy`
|
|
27
|
+
|
|
28
|
+
不要一次性加载所有 Skill,也不要在本 Prompt 中复述它们的完整规则。每一阶段只消费已完成的上游 Artifact,并按对应 `references/artifact-contract.md` 输出。
|
|
29
|
+
|
|
30
|
+
## 不可跳过的停点
|
|
31
|
+
|
|
32
|
+
- 策略推荐者不得批准或独立复核自己的提案。若宿主不能证明复核上下文与提案作者独立,复核阶段必须报告 `blocked`。
|
|
33
|
+
- 独立复核完成后停止并等待外部人工批准。聊天中的认可、模型自我确认或 Forecast 授权都不等于策略执行授权。
|
|
34
|
+
- 只有人工批准明确引用同一 `strategy_proposal` 版本且批准条件已满足,才读取 `fpa-forecast-approved-strategy` 生成正式预测。
|
|
35
|
+
- 本入口的终点是已确认持久化的 `approved_cycle_forecast`。不得加载或调用策略执行 Skill,不得声称已经修改任何外部投放状态。
|
|
36
|
+
|
|
37
|
+
## 全局计算与证据底线
|
|
38
|
+
|
|
39
|
+
- 先聚合可加的分子和分母,再重算比率;不得平均 CPI、CAC、留存率、付费率、LTV 或 ROAS。
|
|
40
|
+
- 缺失、未成熟 cohort 或零分母指标使用 `NULL`,不得写成 `0`。
|
|
41
|
+
- 区分 observed、assumed、modeled、judgment 和 approved;相关性不得冒充因果关系。
|
|
42
|
+
- 每个结论记录实际表、字段、grain、join key、时间范围、`data_as_of`、版本和质量限制。
|
|
43
|
+
|
|
44
|
+
最终交付应包含 `planning_brief`、`actuals_snapshot`、`data_issue_report`、`driver_analysis`、`strategy_scenarios`、`strategy_proposal`、独立 `strategy_review`、外部 `strategy_approval` 证据,以及批准后的 `approved_cycle_forecast`。任何未完成项都必须标注状态和阻塞原因。
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: 在新周期 Actuals 到达后复盘冻结预测,并形成下一周期输入
|
|
3
|
+
argument-hint: "<project-root> <cycle-id> [instructions]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
复盘项目 `$1` 的 FP&A 周期 `$2`。
|
|
7
|
+
|
|
8
|
+
补充要求:`${@:3}`
|
|
9
|
+
|
|
10
|
+
## 输入解析
|
|
11
|
+
|
|
12
|
+
- 把 `$1` 作为项目根并确认它真实存在。
|
|
13
|
+
- 找到该周期不可变的 `approved_cycle_forecast`、对应策略与批准证据,以及覆盖同一业务周期的新 Actuals snapshot。
|
|
14
|
+
- 默认从项目根解析 `LOCAL_FPA_MART_FIELD_CATALOG.md` 和 `LOCAL_FPA_MART_SCHEMA.sql`;补充要求提供其他显式路径时使用显式路径。
|
|
15
|
+
- 明确复盘周期的精确起止时间、业务时区、币种和业务范围。若 Forecast 与 Actuals 不可比,缩窄结论或保持 `blocked`,不得修写历史 Forecast。
|
|
16
|
+
|
|
17
|
+
## 能力路由
|
|
18
|
+
|
|
19
|
+
1. 先读取 `fpa-apply-core-rules`。
|
|
20
|
+
2. 使用 `fpa-diagnose-actuals` 验证新 Actuals 的 grain、完整性、成熟度和可比性。
|
|
21
|
+
3. 使用 `fpa-review-cycle` 对比冻结 Forecast 与 Actuals,生成其 Artifact 合同要求的 `cycle_review`。
|
|
22
|
+
|
|
23
|
+
只按需读取上述 Skill,不要把 Skill 正文复制到结果中。
|
|
24
|
+
|
|
25
|
+
## 复盘底线
|
|
26
|
+
|
|
27
|
+
- 使用冻结 Forecast,不得看到结果后重算基线。
|
|
28
|
+
- 使用 `variance = actual - frozen_forecast`;百分比差异的分母为零时返回 `NULL`。
|
|
29
|
+
- 将偏差区分为执行、量、效率、转化、留存/LTV、结构、外部因素、模型和数据影响;证据不足时标记 unknown,不强行归因。
|
|
30
|
+
- 执行回执只证明动作记录,不证明策略导致结果。
|
|
31
|
+
- 本入口只输出复盘与 `next_cycle_inputs`,不得自动开始下一规划周期,也不得加载或调用策略执行 Skill。
|
|
32
|
+
|
|
33
|
+
最终交付为可追溯的 `cycle_review`:包含 Forecast/Actual 可比性检查、差异与驱动、假设评价、经验、下一周期输入和未决问题。关键输入缺失时列出缺口并保持 `blocked`。
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fpa-analyze-drivers
|
|
3
|
+
description: Explain the measurable operating drivers behind period-over-period changes in revenue, subscriptions, UA, user activity, and First Open cohorts. Use after Actuals pass diagnosis and before constructing or simulating candidate UA strategies.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# FP&A Driver Analysis
|
|
7
|
+
|
|
8
|
+
Load `$fpa-apply-core-rules` first. Explain what changed and why the data supports that conclusion; do not recommend a plan yet.
|
|
9
|
+
|
|
10
|
+
## Procedure
|
|
11
|
+
|
|
12
|
+
1. Consume `planning_brief`, `actuals_snapshot`, and `data_issue_report`.
|
|
13
|
+
2. Recompute headline metrics from eligible additive components with `fpa_query` (cohort metrics with `fpa_cohort`).
|
|
14
|
+
3. Compare the requested period with its declared baseline at like-for-like scope with `fpa_compare`, which returns deltas, percentage changes, and per-slice contribution.
|
|
15
|
+
4. Build metric bridges by App, Store, Product Group, and Channel Group where supported.
|
|
16
|
+
5. Separate volume, rate/efficiency, mix, and data-coverage effects.
|
|
17
|
+
6. Distinguish observed relationships from causal claims and untested hypotheses.
|
|
18
|
+
7. Rank drivers by materiality and decision relevance, then write `driver_analysis` using [artifact-contract.md](references/artifact-contract.md).
|
|
19
|
+
|
|
20
|
+
## Mathematical discipline
|
|
21
|
+
|
|
22
|
+
- For additive metrics, contribution is slice Actual minus slice baseline after scope alignment.
|
|
23
|
+
- For ratios, decompose using their underlying numerator and denominator; never compare averaged ratios.
|
|
24
|
+
- For multiplicative identities such as `spend = paid_installs × CPI`, name the decomposition method and include an interaction/residual term when the chosen method does not allocate it.
|
|
25
|
+
- Reconcile every bridge to the total change within a declared tolerance, using `fpa_calc` for the reconciliation arithmetic.
|
|
26
|
+
- Treat a correlation as a hypothesis unless a causal design or validated response model exists.
|
|
27
|
+
|
|
28
|
+
## Handoff
|
|
29
|
+
|
|
30
|
+
Pass the ranked controllable drivers, non-controllable drivers, risks, and evidence gaps to `$fpa-simulate-strategies`.
|