@viccydev/pi-fpa 0.3.1 → 0.3.3
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 +4 -2
- package/extensions/fpa-data/index.ts +115 -11
- package/extensions/fpa-data/sql.ts +44 -11
- package/extensions/fpa-data/supabase.ts +21 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ Skill:
|
|
|
39
39
|
| `fpa_calc` | 确定性计算器:命名公式求值(四则、abs/min/max/round),NULL 与除零安全传播 |
|
|
40
40
|
| `fpa_compare` | 双期间对比:差值、百分比变化、逐行贡献度全部由代码计算 |
|
|
41
41
|
|
|
42
|
+
`fpa_data_catalog` 默认只读取数据字典和各数据集日期覆盖,不执行随表规模增长的精确行数扫描。确实需要精确 `row_count`、App 数量和 cohort-size 覆盖时显式传入 `include_stats: true`;需要 App 列表时传入 `include_apps: true`。这些补充信息都有独立的 5 秒预算,单个数据集超时会返回对应的 `*_unavailable` 说明,不会丢失其它数据集的覆盖信息。认证、网络、服务端错误和调用方取消仍然抛出。
|
|
43
|
+
|
|
42
44
|
设计契约:**模型不写 SQL、不做任何算术**。模型只从注册表中选择数据集、指标和维度;SQL 生成、数据库聚合和全部派生计算(比率、差异、LTV/ROAS/留存、临时公式)都在 Extension 代码内完成,缺数据或除零返回 `NULL`,绝不编造数值。
|
|
43
45
|
|
|
44
46
|
Extension 内置的关键防护:
|
|
@@ -108,12 +110,12 @@ pi list
|
|
|
108
110
|
团队分发建议使用固定 Git tag:
|
|
109
111
|
|
|
110
112
|
```bash
|
|
111
|
-
pi install git:github.com/linyqh/pi-fpa@v0.3.
|
|
113
|
+
pi install git:github.com/linyqh/pi-fpa@v0.3.2
|
|
112
114
|
```
|
|
113
115
|
|
|
114
116
|
## 发布到 npm
|
|
115
117
|
|
|
116
|
-
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version
|
|
118
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.3.3` 对应 `v0.3.3`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
|
|
117
119
|
|
|
118
120
|
首次发布前需要完成一次仓库配置:
|
|
119
121
|
|
|
@@ -23,16 +23,33 @@ import { CATALOG_CAVEATS, DATASETS, DATASET_IDS } from "./registry.ts";
|
|
|
23
23
|
import {
|
|
24
24
|
buildCohortQuery,
|
|
25
25
|
buildCoverageSql,
|
|
26
|
+
buildCoverageStatsSql,
|
|
26
27
|
buildCohortSizeCoverageSql,
|
|
28
|
+
COVERAGE_TARGETS,
|
|
27
29
|
MAX_LIMIT,
|
|
28
30
|
type QuerySpec,
|
|
29
31
|
} from "./sql.ts";
|
|
30
32
|
import { runStructuredQuery } from "./runtime.ts";
|
|
31
|
-
import { runQuery } from "./supabase.ts";
|
|
33
|
+
import { runQuery, SupabaseQueryTimeoutError, type SqlRow } from "./supabase.ts";
|
|
32
34
|
|
|
33
35
|
const MAX_TOOL_TEXT_CHARS = 100_000;
|
|
34
36
|
const MAX_DISPLAY_ROWS = 200;
|
|
35
37
|
|
|
38
|
+
/** Coverage is essential but one slow dataset must not consume the whole tool budget. */
|
|
39
|
+
const CATALOG_COVERAGE_TIMEOUT_MS = 5_000;
|
|
40
|
+
|
|
41
|
+
/** Explicitly requested supplementary catalog data has a bounded, non-fatal budget. */
|
|
42
|
+
const CATALOG_OPTIONAL_TIMEOUT_MS = 5_000;
|
|
43
|
+
|
|
44
|
+
async function fallbackOnTimeout<T>(work: Promise<T>, signal?: AbortSignal): Promise<T | null> {
|
|
45
|
+
try {
|
|
46
|
+
return await work;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (signal?.aborted || !(error instanceof SupabaseQueryTimeoutError)) throw error;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
const DatasetIdSchema = StringEnum(DATASET_IDS as [string, ...string[]], {
|
|
37
54
|
description: "Dataset id from fpa_data_catalog",
|
|
38
55
|
});
|
|
@@ -126,6 +143,12 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
|
|
|
126
143
|
],
|
|
127
144
|
parameters: Type.Object(
|
|
128
145
|
{
|
|
146
|
+
include_stats: Type.Optional(
|
|
147
|
+
Type.Boolean({
|
|
148
|
+
description:
|
|
149
|
+
"Also request exact row/app counts and cohort-size availability. These are scan-bound and best-effort.",
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
129
152
|
include_apps: Type.Optional(
|
|
130
153
|
Type.Boolean({ description: "Also list distinct app codes (up to 200)." }),
|
|
131
154
|
),
|
|
@@ -134,18 +157,82 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
|
|
|
134
157
|
),
|
|
135
158
|
executionMode: "parallel",
|
|
136
159
|
async execute(_toolCallId, params, signal) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
160
|
+
// Date coverage is the part callers must have. Keep each dataset in its own
|
|
161
|
+
// timeout domain so a missing leading-date index cannot sink the whole catalog;
|
|
162
|
+
// exact scan-bound statistics remain opt-in.
|
|
163
|
+
const coverageRows = await Promise.all(
|
|
164
|
+
COVERAGE_TARGETS.map(async (target) => {
|
|
165
|
+
const rows = await fallbackOnTimeout(
|
|
166
|
+
runQuery(buildCoverageSql(target), {
|
|
167
|
+
signal,
|
|
168
|
+
timeoutMs: CATALOG_COVERAGE_TIMEOUT_MS,
|
|
169
|
+
}),
|
|
170
|
+
signal,
|
|
171
|
+
);
|
|
172
|
+
return rows?.[0] ?? {
|
|
173
|
+
dataset: target.dataset,
|
|
174
|
+
date_min: null,
|
|
175
|
+
date_max: null,
|
|
176
|
+
...(rows === null
|
|
177
|
+
? {
|
|
178
|
+
coverage_unavailable:
|
|
179
|
+
"Date coverage timed out for this dataset; other datasets are unaffected.",
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
182
|
+
};
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
const statsBySource = new Map<string, Promise<SqlRow[] | null>>();
|
|
186
|
+
const statsFor = (target: (typeof COVERAGE_TARGETS)[number]) => {
|
|
187
|
+
const key = `${target.table}\u0000${target.appColumn}`;
|
|
188
|
+
let pending = statsBySource.get(key);
|
|
189
|
+
if (!pending) {
|
|
190
|
+
pending = fallbackOnTimeout(
|
|
191
|
+
runQuery(buildCoverageStatsSql(target), {
|
|
192
|
+
signal,
|
|
193
|
+
timeoutMs: CATALOG_OPTIONAL_TIMEOUT_MS,
|
|
194
|
+
}),
|
|
195
|
+
signal,
|
|
196
|
+
);
|
|
197
|
+
statsBySource.set(key, pending);
|
|
198
|
+
}
|
|
199
|
+
return pending;
|
|
200
|
+
};
|
|
201
|
+
const [statsRows, cohortSize, apps] = await Promise.all([
|
|
202
|
+
params.include_stats
|
|
203
|
+
? Promise.all(
|
|
204
|
+
COVERAGE_TARGETS.map((target) => statsFor(target)),
|
|
205
|
+
)
|
|
206
|
+
: Promise.resolve(COVERAGE_TARGETS.map(() => null)),
|
|
207
|
+
params.include_stats
|
|
208
|
+
? fallbackOnTimeout(runQuery(buildCohortSizeCoverageSql(), { signal, timeoutMs: CATALOG_OPTIONAL_TIMEOUT_MS }), signal)
|
|
209
|
+
: Promise.resolve(null),
|
|
140
210
|
params.include_apps
|
|
141
|
-
?
|
|
142
|
-
|
|
143
|
-
"
|
|
144
|
-
|
|
145
|
-
|
|
211
|
+
? fallbackOnTimeout(
|
|
212
|
+
runQuery(
|
|
213
|
+
"select app_code, string_agg(distinct platform, ',' order by platform) as platforms " +
|
|
214
|
+
"from appsflyer_ua_campaign_daily where app_code is not null " +
|
|
215
|
+
"group by 1 order by 1 limit 200",
|
|
216
|
+
{ signal, timeoutMs: CATALOG_OPTIONAL_TIMEOUT_MS },
|
|
217
|
+
),
|
|
218
|
+
signal,
|
|
146
219
|
)
|
|
147
220
|
: Promise.resolve<SqlRow[]>([]),
|
|
148
221
|
]);
|
|
222
|
+
const coverage = coverageRows.map((row, index) => {
|
|
223
|
+
const stats = statsRows[index]?.[0];
|
|
224
|
+
return stats
|
|
225
|
+
? { ...row, ...stats }
|
|
226
|
+
: {
|
|
227
|
+
...row,
|
|
228
|
+
row_count: null,
|
|
229
|
+
apps: null,
|
|
230
|
+
updated_at: null,
|
|
231
|
+
stats_unavailable: params.include_stats
|
|
232
|
+
? "Row counts timed out; date coverage above is unaffected."
|
|
233
|
+
: "Not requested; set include_stats=true for exact row and app counts.",
|
|
234
|
+
};
|
|
235
|
+
});
|
|
149
236
|
|
|
150
237
|
return toolResult(
|
|
151
238
|
{
|
|
@@ -165,9 +252,26 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
|
|
|
165
252
|
notes: d.notes,
|
|
166
253
|
})),
|
|
167
254
|
coverage,
|
|
168
|
-
cohort_size_available: cohortSize[0] ?? null,
|
|
255
|
+
cohort_size_available: cohortSize?.[0] ?? null,
|
|
256
|
+
...(cohortSize?.[0]
|
|
257
|
+
? {}
|
|
258
|
+
: {
|
|
259
|
+
cohort_size_unavailable: params.include_stats
|
|
260
|
+
? "Cohort-size coverage timed out; dataset date coverage is unaffected."
|
|
261
|
+
: "Not requested; set include_stats=true for cohort-size availability.",
|
|
262
|
+
}),
|
|
169
263
|
caveats: CATALOG_CAVEATS,
|
|
170
|
-
...(params.include_apps
|
|
264
|
+
...(params.include_apps
|
|
265
|
+
? {
|
|
266
|
+
apps,
|
|
267
|
+
...(apps === null
|
|
268
|
+
? {
|
|
269
|
+
apps_unavailable:
|
|
270
|
+
"App list timed out; dataset definitions and date coverage are unaffected.",
|
|
271
|
+
}
|
|
272
|
+
: {}),
|
|
273
|
+
}
|
|
274
|
+
: {}),
|
|
171
275
|
},
|
|
172
276
|
"coverage",
|
|
173
277
|
);
|
|
@@ -442,17 +442,50 @@ export function buildCohortQuery(spec: CohortSpec): BuiltCohortQuery {
|
|
|
442
442
|
return { sql, spendSql, asOfSql, horizons, bucket, groupBy };
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
-
/**
|
|
446
|
-
export
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
445
|
+
/** One row of the fpa_data_catalog coverage block. */
|
|
446
|
+
export interface CoverageTarget {
|
|
447
|
+
dataset: string;
|
|
448
|
+
table: string;
|
|
449
|
+
dateColumn: string;
|
|
450
|
+
appColumn: string;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export const COVERAGE_TARGETS: CoverageTarget[] = [
|
|
454
|
+
{ dataset: "ua_spend", table: "appsflyer_ua_campaign_daily", dateColumn: "install_date", appColumn: "app_code" },
|
|
455
|
+
{ dataset: "revenue_activity", table: "appsflyer_install_cohort_daily", dateColumn: "event_date", appColumn: "app_code" },
|
|
456
|
+
{ dataset: "cohort (fpa_cohort)", table: "appsflyer_install_cohort_daily", dateColumn: "install_date", appColumn: "app_code" },
|
|
457
|
+
{ dataset: "apple_store", table: "apple_app_daily", dateColumn: "metric_date", appColumn: "adam_id" },
|
|
458
|
+
{ dataset: "product_events", table: "appsflyer_event_daily", dateColumn: "event_date", appColumn: "app_code" },
|
|
459
|
+
{ dataset: "mixpanel_product", table: "mixpanel_app_daily", dateColumn: "business_date", appColumn: "app_code" },
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Answerable date range for one dataset.
|
|
464
|
+
*
|
|
465
|
+
* min()/max() alone let Postgres walk the date index instead of scanning the table.
|
|
466
|
+
* Mixing them with count(*) in a single statement — as the old six-way UNION did —
|
|
467
|
+
* forces a sequential scan per arm, which is what pushed fpa_data_catalog past its
|
|
468
|
+
* timeout as the mart grew.
|
|
469
|
+
*/
|
|
470
|
+
export function buildCoverageSql(target: CoverageTarget): string {
|
|
471
|
+
return assertReadOnly(
|
|
472
|
+
`select ${escapeLiteral(target.dataset)} as dataset, ` +
|
|
473
|
+
`min(${target.dateColumn})::text as date_min, max(${target.dateColumn})::text as date_max ` +
|
|
474
|
+
`from ${target.table}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Row counts and freshness for one dataset. These need a full scan (count(distinct)
|
|
480
|
+
* most of all), so callers treat them as best-effort and report null when they time out
|
|
481
|
+
* rather than failing the whole catalog.
|
|
482
|
+
*/
|
|
483
|
+
export function buildCoverageStatsSql(target: CoverageTarget): string {
|
|
484
|
+
return assertReadOnly(
|
|
485
|
+
`select count(*) as row_count, count(distinct ${target.appColumn}) as apps, ` +
|
|
486
|
+
`max(updated_at)::text as updated_at ` +
|
|
487
|
+
`from ${target.table}`,
|
|
488
|
+
);
|
|
456
489
|
}
|
|
457
490
|
|
|
458
491
|
/** Cohort-size availability window, reported by fpa_data_catalog. */
|
|
@@ -35,6 +35,15 @@ export function resolveConfig(env: Record<string, string | undefined> = process.
|
|
|
35
35
|
|
|
36
36
|
export type SqlRow = Record<string, unknown>;
|
|
37
37
|
|
|
38
|
+
export class SupabaseQueryTimeoutError extends Error {
|
|
39
|
+
readonly code = "SUPABASE_QUERY_TIMEOUT";
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
super("Supabase query timed out. Narrow the date range or filters.");
|
|
43
|
+
this.name = "SupabaseQueryTimeoutError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
export async function runQuery(
|
|
39
48
|
sql: string,
|
|
40
49
|
options: { signal?: AbortSignal; timeoutMs?: number; config?: SupabaseConfig } = {},
|
|
@@ -45,6 +54,7 @@ export async function runQuery(
|
|
|
45
54
|
if (options.signal) signals.push(options.signal);
|
|
46
55
|
|
|
47
56
|
let response: Response;
|
|
57
|
+
let body: string;
|
|
48
58
|
try {
|
|
49
59
|
response = await fetch(
|
|
50
60
|
`${API_BASE}/v1/projects/${config.projectRef}/database/query`,
|
|
@@ -58,16 +68,19 @@ export async function runQuery(
|
|
|
58
68
|
signal: AbortSignal.any(signals),
|
|
59
69
|
},
|
|
60
70
|
);
|
|
71
|
+
body = await response.text();
|
|
61
72
|
} catch (error) {
|
|
73
|
+
if (options.signal?.aborted) {
|
|
74
|
+
throw options.signal.reason ?? error;
|
|
75
|
+
}
|
|
62
76
|
if (error instanceof Error && error.name === "TimeoutError") {
|
|
63
|
-
throw new
|
|
77
|
+
throw new SupabaseQueryTimeoutError();
|
|
64
78
|
}
|
|
65
79
|
throw new Error(
|
|
66
80
|
`Could not reach the Supabase Management API: ${error instanceof Error ? error.message : String(error)}`,
|
|
67
81
|
);
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
const body = await response.text();
|
|
71
84
|
if (new TextEncoder().encode(body).byteLength > MAX_RESPONSE_BYTES) {
|
|
72
85
|
throw new Error(
|
|
73
86
|
`Supabase response exceeds ${MAX_RESPONSE_BYTES} bytes. Narrow the query with filters, a shorter date range, or a lower limit.`,
|
|
@@ -86,6 +99,12 @@ export async function runQuery(
|
|
|
86
99
|
payload && typeof payload === "object" && "message" in payload
|
|
87
100
|
? String((payload as { message: unknown }).message)
|
|
88
101
|
: body.slice(0, 500);
|
|
102
|
+
if (
|
|
103
|
+
response.status === 504 ||
|
|
104
|
+
/(?:statement|query|gateway) (?:timed out|timeout)/i.test(detail)
|
|
105
|
+
) {
|
|
106
|
+
throw new SupabaseQueryTimeoutError();
|
|
107
|
+
}
|
|
89
108
|
throw new Error(`Supabase Management API error ${response.status}: ${detail}`);
|
|
90
109
|
}
|
|
91
110
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viccydev/pi-fpa",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"extensions"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
26
|
+
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
27
27
|
"test:structure": "node tests/package-structure.test.mjs",
|
|
28
|
-
"test:unit": "node tests/extension-unit.test.mjs && node --test tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
|
|
28
|
+
"test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
|
|
29
29
|
"test:loader": "node tests/pi-loader-smoke.mjs",
|
|
30
30
|
"test:live": "node tests/live-smoke.mjs",
|
|
31
31
|
"pack:check": "npm pack --dry-run"
|