@viccydev/pi-fpa 0.8.0 → 0.9.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 +15 -4
- package/extensions/fpa-artifacts/compose.ts +156 -42
- package/extensions/fpa-artifacts/contracts.ts +202 -2
- package/extensions/fpa-artifacts/forecast-quality.ts +138 -0
- package/extensions/fpa-artifacts/index.ts +24 -1
- package/extensions/fpa-dashboard/coordinator.ts +2 -2
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +5 -4
- package/extensions/fpa-dashboard/forward-outlook.ts +5 -2
- package/extensions/fpa-dashboard/index.ts +12 -3
- package/extensions/fpa-dashboard/projector.ts +58 -1
- package/extensions/fpa-dashboard/service.ts +13 -2
- package/extensions/fpa-dashboard/stage-projector.ts +131 -10
- package/extensions/fpa-routing-guard/graph-installer.ts +234 -0
- package/extensions/fpa-routing-guard/index.ts +114 -28
- package/graphs/fpa-forecast-freeze.json +103 -7
- package/graphs/fpa-strategy-planning.json +159 -0
- package/package.json +2 -2
- package/prompts/fpa-plan-cycle.md +12 -12
- package/skills/fpa-apply-core-rules/SKILL.md +8 -8
- package/skills/fpa-apply-core-rules/references/core-rules.md +11 -13
- package/skills/fpa-diagnose-actuals/references/artifact-contract.md +2 -0
- package/skills/fpa-forecast-approved-strategy/SKILL.md +12 -0
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +26 -1
- package/skills/fpa-recommend-strategy/SKILL.md +1 -1
- package/skills/fpa-recommend-strategy/references/artifact-contract.md +2 -1
- package/skills/fpa-refresh-dashboard/SKILL.md +7 -4
- package/skills/fpa-review-strategy/references/artifact-contract.md +22 -0
- package/graphs/fpa-period-analysis.json +0 -10
- package/graphs/fpa-strategy-recommendation.json +0 -11
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sliceKey,
|
|
3
|
+
type ForecastDataQuality,
|
|
4
|
+
type ForecastDataQualityIssue,
|
|
5
|
+
} from "./contracts.ts";
|
|
6
|
+
|
|
7
|
+
export interface ForecastQualitySlice {
|
|
8
|
+
app_id: string;
|
|
9
|
+
store: string;
|
|
10
|
+
channel_group: string;
|
|
11
|
+
approved_spend: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ForecastDimensionSnapshot {
|
|
15
|
+
app_code: Set<string>;
|
|
16
|
+
platform: Set<string>;
|
|
17
|
+
media_source: Set<string>;
|
|
18
|
+
/** Canonical app_code / platform / media_source combinations observed in ua_spend. */
|
|
19
|
+
tuples?: Set<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ForecastQualityAssessment {
|
|
23
|
+
portfolioAppId: string | null;
|
|
24
|
+
eligibleSliceKeys: Set<string>;
|
|
25
|
+
limitedSliceKeys: Set<string>;
|
|
26
|
+
dataQuality: ForecastDataQuality;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sample(values: Set<string>, limit = 8): string {
|
|
30
|
+
const list = [...values].sort();
|
|
31
|
+
return list.length <= limit ? list.join(", ") : `${list.slice(0, limit).join(", ")}, … (${list.length} total)`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function issueForSlice(
|
|
35
|
+
slice: ForecastQualitySlice,
|
|
36
|
+
index: number,
|
|
37
|
+
evidence: string[],
|
|
38
|
+
): ForecastDataQualityIssue {
|
|
39
|
+
return {
|
|
40
|
+
issue_id: `DQ-SLICE-${String(index + 1).padStart(3, "0")}`,
|
|
41
|
+
code: "slice_key_not_in_actuals",
|
|
42
|
+
severity: "warning",
|
|
43
|
+
slice: {
|
|
44
|
+
app_id: slice.app_id,
|
|
45
|
+
store: slice.store,
|
|
46
|
+
channel_group: slice.channel_group,
|
|
47
|
+
},
|
|
48
|
+
affected_metrics: ["actuals_comparison", "revenue", "roas", "execution"],
|
|
49
|
+
disposition: "excluded_from_calculation",
|
|
50
|
+
evidence,
|
|
51
|
+
remediation:
|
|
52
|
+
"请由数据工作人员修正 ua_spend 的 app_code/platform/media_source 映射,或更新计划切片为数据集中真实存在的组合。",
|
|
53
|
+
owner_role: "data_steward",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Classify slice identity problems as data-quality limits, not governance
|
|
59
|
+
* failures. The allocation remains intact; callers use eligibleSliceKeys to
|
|
60
|
+
* decide which derived metrics may be calculated and which slices may execute.
|
|
61
|
+
*/
|
|
62
|
+
export function assessForecastQuality(
|
|
63
|
+
slices: ForecastQualitySlice[],
|
|
64
|
+
values: ForecastDimensionSnapshot,
|
|
65
|
+
): ForecastQualityAssessment {
|
|
66
|
+
const appIds = new Set(slices.map((slice) => slice.app_id));
|
|
67
|
+
const collapsed = appIds.size === 1 ? [...appIds][0] : null;
|
|
68
|
+
const portfolioAppId = collapsed !== null && !values.app_code.has(collapsed) ? collapsed : null;
|
|
69
|
+
const eligibleSliceKeys = new Set<string>();
|
|
70
|
+
const limitedSliceKeys = new Set<string>();
|
|
71
|
+
const issues: ForecastDataQualityIssue[] = [];
|
|
72
|
+
|
|
73
|
+
for (const [index, slice] of slices.entries()) {
|
|
74
|
+
const evidence: string[] = [];
|
|
75
|
+
if (portfolioAppId === null && !values.app_code.has(slice.app_id)) {
|
|
76
|
+
evidence.push(`app_id ${JSON.stringify(slice.app_id)} is not a ua_spend.app_code value. Valid values: ${sample(values.app_code)}.`);
|
|
77
|
+
}
|
|
78
|
+
if (!values.platform.has(slice.store)) {
|
|
79
|
+
evidence.push(`store ${JSON.stringify(slice.store)} is not a ua_spend.platform value. Valid values: ${sample(values.platform)}.`);
|
|
80
|
+
}
|
|
81
|
+
if (!values.media_source.has(slice.channel_group)) {
|
|
82
|
+
evidence.push(`channel_group ${JSON.stringify(slice.channel_group)} is not a ua_spend.media_source value. Valid values: ${sample(values.media_source)}.`);
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
evidence.length === 0
|
|
86
|
+
&& portfolioAppId === null
|
|
87
|
+
&& values.tuples
|
|
88
|
+
&& !values.tuples.has(sliceKey(slice))
|
|
89
|
+
) {
|
|
90
|
+
evidence.push(
|
|
91
|
+
`The app_code/platform/media_source combination ${slice.app_id} / ${slice.store} / ${slice.channel_group} does not occur in ua_spend.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
evidence.length === 0
|
|
96
|
+
&& portfolioAppId !== null
|
|
97
|
+
&& values.tuples
|
|
98
|
+
&& ![...values.tuples].some((tuple) => tuple.endsWith(`\u0000${slice.store}\u0000${slice.channel_group}`))
|
|
99
|
+
) {
|
|
100
|
+
evidence.push(
|
|
101
|
+
`The platform/media_source combination ${slice.store} / ${slice.channel_group} does not occur in ua_spend portfolio data.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const key = sliceKey(slice);
|
|
106
|
+
if (evidence.length === 0) eligibleSliceKeys.add(key);
|
|
107
|
+
else {
|
|
108
|
+
limitedSliceKeys.add(key);
|
|
109
|
+
issues.push(issueForSlice(slice, index, evidence));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const plannedSpend = slices.reduce((total, slice) => total + slice.approved_spend, 0);
|
|
114
|
+
const calculableSpend = slices
|
|
115
|
+
.filter((slice) => eligibleSliceKeys.has(sliceKey(slice)))
|
|
116
|
+
.reduce((total, slice) => total + slice.approved_spend, 0);
|
|
117
|
+
const excludedSpend = plannedSpend - calculableSpend;
|
|
118
|
+
const status = issues.length === 0
|
|
119
|
+
? "complete"
|
|
120
|
+
: calculableSpend === 0
|
|
121
|
+
? "unavailable"
|
|
122
|
+
: "partial";
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
portfolioAppId,
|
|
126
|
+
eligibleSliceKeys,
|
|
127
|
+
limitedSliceKeys,
|
|
128
|
+
dataQuality: {
|
|
129
|
+
status,
|
|
130
|
+
planned_spend: plannedSpend,
|
|
131
|
+
calculable_spend: calculableSpend,
|
|
132
|
+
calculable_spend_pct: plannedSpend === 0 ? (issues.length === 0 ? 1 : 0) : calculableSpend / plannedSpend,
|
|
133
|
+
excluded_spend: excludedSpend,
|
|
134
|
+
issue_count: issues.length,
|
|
135
|
+
issues,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import { Type } from "typebox";
|
|
3
5
|
|
|
@@ -7,6 +9,7 @@ import {
|
|
|
7
9
|
commitArtifactFromPath,
|
|
8
10
|
readArtifactByRef,
|
|
9
11
|
readProjectJsonFile,
|
|
12
|
+
stableJson,
|
|
10
13
|
type ArtifactRefV2,
|
|
11
14
|
} from "./store.ts";
|
|
12
15
|
|
|
@@ -115,12 +118,32 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
115
118
|
},
|
|
116
119
|
});
|
|
117
120
|
|
|
121
|
+
pi.registerTool({
|
|
122
|
+
name: "fpa_json_fingerprint",
|
|
123
|
+
label: "Fingerprint FP&A JSON Artifacts",
|
|
124
|
+
description: "Read project-local JSON artifacts and return deterministic SHA-256 fingerprints over their canonical JSON form. Use this to bind reviewed handoffs to exact proposal and review bodies.",
|
|
125
|
+
promptSnippet: "Fingerprint exact FP&A JSON artifacts for a version-bound handoff",
|
|
126
|
+
parameters: Type.Object({
|
|
127
|
+
paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 8 }),
|
|
128
|
+
}, { additionalProperties: false }),
|
|
129
|
+
executionMode: "parallel",
|
|
130
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
131
|
+
if (new Set(params.paths).size !== params.paths.length) throw new Error("Fingerprint paths must be unique.");
|
|
132
|
+
const entries = await Promise.all(params.paths.map(async (path) => {
|
|
133
|
+
const value = await readProjectJsonFile(ctx.cwd, path, "path");
|
|
134
|
+
return [path, createHash("sha256").update(stableJson(value)).digest("hex")] as const;
|
|
135
|
+
}));
|
|
136
|
+
return toolResult({ status: "fingerprinted", fingerprints: Object.fromEntries(entries) });
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
118
140
|
pi.registerTool({
|
|
119
141
|
name: "fpa_forecast_compose",
|
|
120
142
|
label: "Compose Approved Forecast",
|
|
121
143
|
description:
|
|
122
144
|
"Derive a complete, self-consistent approved_cycle_forecast from a compact plan file holding only the approved allocation and its per-slice ROAS assumptions. " +
|
|
123
|
-
"Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks
|
|
145
|
+
"Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks plan slice keys against the exact dimension combinations ua_spend actually uses. " +
|
|
146
|
+
"Data-quality mismatches are isolated into complete_with_limits with coverage and repair diagnostics; approved allocation is never dropped or renormalized. " +
|
|
124
147
|
"Returns the artifact for fpa_artifact_commit to freeze; writes nothing.",
|
|
125
148
|
promptSnippet: "Derive a complete approved forecast from a compact plan",
|
|
126
149
|
promptGuidelines: [
|
|
@@ -717,8 +717,8 @@ async function defaultWorker(event: DashboardRefreshEvent, projectRoot: string,
|
|
|
717
717
|
...(event.forward_forecast_refs ? { forwardForecastRefs: event.forward_forecast_refs } : {}),
|
|
718
718
|
}, signal);
|
|
719
719
|
if (result.sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${result.sliceKeyMismatch}`);
|
|
720
|
-
if (result.forecast.status
|
|
721
|
-
throw new Error("Dashboard publish requires a
|
|
720
|
+
if (result.forecast.status === "blocked" || !result.forecast.approval_conditions_satisfied) {
|
|
721
|
+
throw new Error("Dashboard publish requires a completed approved forecast with all approval conditions satisfied.");
|
|
722
722
|
}
|
|
723
723
|
const generationId = dashboardBuildFingerprint(result.build, result.projector);
|
|
724
724
|
if (event.desired_generation_id && generationId !== event.desired_generation_id) {
|
|
@@ -218,8 +218,8 @@ export function projectCycleOperatingProjection(input: CycleOperatingProjectionI
|
|
|
218
218
|
throw new Error("next_forecast target period must be the exact successor of the current cycle in the same timezone.");
|
|
219
219
|
}
|
|
220
220
|
if (next && next.reporting_currency !== current.reporting_currency) throw new Error("next_forecast reporting_currency must match current_forecast.");
|
|
221
|
-
if (next && (next.status
|
|
222
|
-
throw new Error("next_forecast must be
|
|
221
|
+
if (next && (next.status === "blocked" || !next.approval_conditions_satisfied)) {
|
|
222
|
+
throw new Error("next_forecast must be completed with approval conditions satisfied.");
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
const forecastSpend = scenario(current.consolidated_forecast.spend, "current_forecast.consolidated_forecast.spend");
|
|
@@ -253,9 +253,10 @@ export function projectCycleOperatingProjection(input: CycleOperatingProjectionI
|
|
|
253
253
|
const nextSpend = next ? scenario(next.consolidated_forecast.spend, "next_forecast.consolidated_forecast.spend") : null;
|
|
254
254
|
const nextRevenue = next ? scenario(next.consolidated_forecast.revenue, "next_forecast.consolidated_forecast.revenue") : null;
|
|
255
255
|
const nextRoas = next ? scenario(next.consolidated_forecast.roas, "next_forecast.consolidated_forecast.roas") : null;
|
|
256
|
-
const nextHasLimits = !!next && (
|
|
256
|
+
const nextHasLimits = !!next && (next.status === "complete_with_limits"
|
|
257
|
+
|| nextSpend?.base === null || nextRevenue?.base === null || nextRoas?.base === null
|
|
257
258
|
|| next.approved_allocation.some((item) => !item.owner));
|
|
258
|
-
if (nextHasLimits) warnings.push("The next-cycle forecast is approved with
|
|
259
|
+
if (nextHasLimits) warnings.push("The next-cycle forecast is approved with limits; unsupported expectations remain null and documented data issues still require remediation.");
|
|
259
260
|
if (next?.approved_allocation.some((item) => !item.owner)) warnings.push("At least one next-cycle allocation has no accountable owner; strategy readiness is limited.");
|
|
260
261
|
|
|
261
262
|
return {
|
|
@@ -26,7 +26,7 @@ export interface ForwardOutlookProjection {
|
|
|
26
26
|
function approvedForecast(value: unknown, path: string): ApprovedCycleForecastInput {
|
|
27
27
|
const artifact = validateArtifact(value);
|
|
28
28
|
if (artifact.artifact_type !== "approved_cycle_forecast") throw new Error(`${path} must be an approved_cycle_forecast.`);
|
|
29
|
-
if (artifact.status
|
|
29
|
+
if (artifact.status === "blocked" || !artifact.approval_conditions_satisfied) throw new Error(`${path} must be completed and approved.`);
|
|
30
30
|
return artifact;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -46,6 +46,7 @@ export function projectForwardOutlook(input: {
|
|
|
46
46
|
const current = approvedForecast(input.current_forecast, "current_forecast");
|
|
47
47
|
if (!Array.isArray(input.forward_forecasts) || input.forward_forecasts.length > 6) throw new Error("forward_forecasts must contain at most six approved forecasts.");
|
|
48
48
|
const months: ForwardOutlookProjection["months"] = [];
|
|
49
|
+
const warnings: string[] = [];
|
|
49
50
|
let expectedStart = current.target_period.end_exclusive;
|
|
50
51
|
for (const [index, item] of input.forward_forecasts.entries()) {
|
|
51
52
|
const forecast = approvedForecast(item.forecast, `forward_forecasts[${index}].forecast`);
|
|
@@ -63,8 +64,10 @@ export function projectForwardOutlook(input: {
|
|
|
63
64
|
expected_revenue: values(forecast.consolidated_forecast.revenue, `forward_forecasts[${index}].revenue`),
|
|
64
65
|
expected_roas: values(forecast.consolidated_forecast.roas, `forward_forecasts[${index}].roas`),
|
|
65
66
|
});
|
|
67
|
+
if (forecast.status === "complete_with_limits") {
|
|
68
|
+
warnings.push(`${forecast.forecast_version} has data limits; unsupported expectations remain null.`);
|
|
69
|
+
}
|
|
66
70
|
}
|
|
67
|
-
const warnings: string[] = [];
|
|
68
71
|
if (months.length < 6) warnings.push(`Only ${months.length} of 6 forward monthly forecasts are approved and linked.`);
|
|
69
72
|
if (months.some((month) => month.expected_revenue.base === null || month.expected_spend.base === null || month.expected_roas.base === null)) {
|
|
70
73
|
warnings.push("At least one forward month has unsupported base metrics; its expectation remains unavailable.");
|
|
@@ -114,7 +114,11 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
114
114
|
parameters: Type.Object({
|
|
115
115
|
mode: StringEnum(["preview", "publish"] as const),
|
|
116
116
|
proposal_path: Type.String({ minLength: 1 }),
|
|
117
|
+
review_path: Type.String({ minLength: 1 }),
|
|
117
118
|
handoff_path: Type.String({ minLength: 1 }),
|
|
119
|
+
scope_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
120
|
+
cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
121
|
+
forecast_role: Type.String({ minLength: 1, maxLength: 64 }),
|
|
118
122
|
expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
|
|
119
123
|
expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
|
|
120
124
|
dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
@@ -122,8 +126,13 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
122
126
|
executionMode: "sequential",
|
|
123
127
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
124
128
|
const proposal = await readProjectJsonFile(ctx.cwd, params.proposal_path, "proposal_path");
|
|
129
|
+
const review = await readProjectJsonFile(ctx.cwd, params.review_path, "review_path");
|
|
125
130
|
const handoff = await readProjectJsonFile(ctx.cwd, params.handoff_path, "handoff_path");
|
|
126
|
-
const module = projectStrategyModule(proposal, handoff
|
|
131
|
+
const module = projectStrategyModule(proposal, handoff, review, {
|
|
132
|
+
scope_id: params.scope_id,
|
|
133
|
+
cycle_id: params.cycle_id,
|
|
134
|
+
forecast_role: params.forecast_role,
|
|
135
|
+
});
|
|
127
136
|
const previewFingerprint = dashboardModuleBuildFingerprint(module);
|
|
128
137
|
const current = await readDashboardModuleManifest(ctx.cwd);
|
|
129
138
|
if (params.mode === "preview") return toolResult({
|
|
@@ -375,8 +384,8 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
375
384
|
}
|
|
376
385
|
|
|
377
386
|
if (sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${sliceKeyMismatch}`);
|
|
378
|
-
if (forecast.status
|
|
379
|
-
throw new Error("Dashboard publish requires a
|
|
387
|
+
if (forecast.status === "blocked" || !forecast.approval_conditions_satisfied) {
|
|
388
|
+
throw new Error("Dashboard publish requires a completed approved forecast with all approval conditions satisfied.");
|
|
380
389
|
}
|
|
381
390
|
if (!params.expected_preview_fingerprint) throw new Error("Publish requires expected_preview_fingerprint from a preceding preview.");
|
|
382
391
|
if (params.expected_preview_fingerprint !== previewFingerprint) {
|
|
@@ -226,10 +226,11 @@ function joinSlices(
|
|
|
226
226
|
const forecastByKey = new Map(forecast.forecast_by_slice.map((slice) => [sliceKey(slice), slice]));
|
|
227
227
|
const actualByKey = new Map(actuals.slices.map((slice) => [sliceKey(slice), slice]));
|
|
228
228
|
const executionByKey = new Map((execution?.slices ?? []).map((slice) => [sliceKey(slice), slice]));
|
|
229
|
+
const isolatedKeys = new Set((forecast.data_quality?.issues ?? []).map((issue) => sliceKey(issue.slice)));
|
|
229
230
|
return forecast.approved_allocation.map((allocation) => {
|
|
230
231
|
const forecastSlice = forecastByKey.get(sliceKey(allocation));
|
|
231
232
|
if (!forecastSlice) throw new Error(`Forecast slice is missing for ${allocation.app_id} / ${allocation.store} / ${allocation.channel_group}.`);
|
|
232
|
-
const actual = actualByKey.get(sliceKey(allocation)) ?? null;
|
|
233
|
+
const actual = isolatedKeys.has(sliceKey(allocation)) ? null : actualByKey.get(sliceKey(allocation)) ?? null;
|
|
233
234
|
const forecastRoas = safeDiv(
|
|
234
235
|
forecastSlice.metrics.revenue?.base ?? null,
|
|
235
236
|
forecastSlice.metrics.spend?.base ?? null,
|
|
@@ -390,8 +391,64 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
|
|
|
390
391
|
const deviationCount = alerts.rows.length - stopCount;
|
|
391
392
|
const window = `${formatPeriodDate(forecast.target_period.start_inclusive, forecast.target_period.timezone, locale)} → ${formatPeriodDate(forecast.target_period.end_exclusive, forecast.target_period.timezone, locale)}(end exclusive)`;
|
|
392
393
|
const asOfNote = actuals.data_as_of === ACTUALS_DATA_AS_OF_UNAVAILABLE ? "本周期暂无 Actuals" : `Actuals 截至 ${actuals.data_as_of}`;
|
|
394
|
+
const quality = forecast.data_quality;
|
|
395
|
+
const qualityWidgets: DashboardWidget[] = !quality || quality.status === "complete" ? [] : [
|
|
396
|
+
{
|
|
397
|
+
id: "forecast-data-coverage",
|
|
398
|
+
type: "stat",
|
|
399
|
+
span: "full",
|
|
400
|
+
dataset: "forecast-data-coverage.json",
|
|
401
|
+
data: {
|
|
402
|
+
label: "预测数据覆盖率",
|
|
403
|
+
value: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`,
|
|
404
|
+
description: `可计算预算 ${formatCurrency(quality.calculable_spend, forecast.reporting_currency, locale)} / 计划预算 ${formatCurrency(quality.planned_spend, forecast.reporting_currency, locale)}`,
|
|
405
|
+
footnote: forecast.conclusion?.warning ?? "异常切片未参与收入与 ROAS 计算,批准预算未被删除或重分配。",
|
|
406
|
+
progress: { fraction: quality.calculable_spend_pct, label: `${quality.issue_count} 个待修复切片`, tone: "warning" },
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: "forecast-data-issues",
|
|
411
|
+
type: "table",
|
|
412
|
+
span: "full",
|
|
413
|
+
dataset: "forecast-data-issues.json",
|
|
414
|
+
data: {
|
|
415
|
+
label: "数据待修复项",
|
|
416
|
+
description: "问题切片保持在批准计划中,但不参与预测计算、Actuals 比较或执行。",
|
|
417
|
+
columns: [
|
|
418
|
+
{ key: "slice", label: "App / 商店 / 渠道" },
|
|
419
|
+
{ key: "problem", label: "数据问题" },
|
|
420
|
+
{ key: "remediation", label: "修复建议" },
|
|
421
|
+
{ key: "owner", label: "负责人" },
|
|
422
|
+
],
|
|
423
|
+
rows: quality.issues.map((issue) => ({
|
|
424
|
+
slice: `${issue.slice.app_id} · ${issue.slice.store} · ${issue.slice.channel_group}`,
|
|
425
|
+
problem: issue.evidence.join("\n"),
|
|
426
|
+
remediation: issue.remediation,
|
|
427
|
+
owner: issue.owner_role,
|
|
428
|
+
})),
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
const generalLimits = forecast.unsupported_metrics.filter((item) => {
|
|
433
|
+
return !(item && typeof item === "object" && !Array.isArray(item)
|
|
434
|
+
&& (item as Record<string, unknown>).reason === "slice_key_not_in_actuals");
|
|
435
|
+
});
|
|
436
|
+
const limitWidgets: DashboardWidget[] = generalLimits.length === 0 ? [] : [{
|
|
437
|
+
id: "forecast-limitations",
|
|
438
|
+
type: "table",
|
|
439
|
+
span: "full",
|
|
440
|
+
dataset: "forecast-limitations.json",
|
|
441
|
+
data: {
|
|
442
|
+
label: "预测限制提醒",
|
|
443
|
+
description: "以下上游数据或指标限制未阻断预测;不支持的值保持为空,待数据工作人员修复。",
|
|
444
|
+
columns: [{ key: "item", label: "限制与修复信息" }],
|
|
445
|
+
rows: generalLimits.map((item) => ({ item: typeof item === "string" ? item : JSON.stringify(item) })),
|
|
446
|
+
},
|
|
447
|
+
}];
|
|
393
448
|
|
|
394
449
|
const widgets: DashboardWidget[] = [
|
|
450
|
+
...qualityWidgets,
|
|
451
|
+
...limitWidgets,
|
|
395
452
|
{
|
|
396
453
|
id: "revenue-vs-forecast",
|
|
397
454
|
type: "stat",
|
|
@@ -173,7 +173,18 @@ export async function buildDashboardProjection(
|
|
|
173
173
|
if (actuals.query_receipts.some((receipt) => receipt.dataset === "ua_spend.slices.discovery" && receipt.row_count >= 1000)) build.warnings.push("Unplanned-slice discovery reached its 1000-row safety limit; approved slices and like-for-like totals remain complete, but additional warnings may be omitted.");
|
|
174
174
|
if (actuals.data_as_of === "unavailable") build.warnings.push("No current-period UA Actuals are available.");
|
|
175
175
|
|
|
176
|
-
const
|
|
177
|
-
|
|
176
|
+
const detectedSliceKeyMismatch = detectSliceKeyMismatch(forecast.approved_allocation, actuals);
|
|
177
|
+
const qualityIssueKeys = new Set((forecast.data_quality?.issues ?? []).map((issue) => sliceKey(issue.slice)));
|
|
178
|
+
const qualityAcknowledgesMismatch = forecast.data_quality !== undefined
|
|
179
|
+
&& forecast.data_quality.status !== "complete"
|
|
180
|
+
&& forecast.approved_allocation
|
|
181
|
+
.filter((allocation) => allocation.approved_spend > 0)
|
|
182
|
+
.every((allocation) => qualityIssueKeys.has(sliceKey(allocation)));
|
|
183
|
+
const sliceKeyMismatch = detectedSliceKeyMismatch && !qualityAcknowledgesMismatch ? detectedSliceKeyMismatch : null;
|
|
184
|
+
if (detectedSliceKeyMismatch) {
|
|
185
|
+
build.warnings.push(qualityAcknowledgesMismatch
|
|
186
|
+
? `${detectedSliceKeyMismatch} The affected slices are already isolated by forecast data quality, so dashboard publication continues with limits.`
|
|
187
|
+
: detectedSliceKeyMismatch);
|
|
188
|
+
}
|
|
178
189
|
return { build, forecast, forecastRead, executionRead, actuals, sliceKeyMismatch, ...provenance };
|
|
179
190
|
}
|
|
@@ -6,6 +6,20 @@ import type { TableCell } from "./projector.ts";
|
|
|
6
6
|
import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
|
|
7
7
|
import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
|
|
8
8
|
|
|
9
|
+
const STRATEGY_ACTION_LABELS = {
|
|
10
|
+
grow: "增长",
|
|
11
|
+
hold: "保持",
|
|
12
|
+
cut: "削减",
|
|
13
|
+
stop: "止损",
|
|
14
|
+
} as const;
|
|
15
|
+
type StrategyAction = keyof typeof STRATEGY_ACTION_LABELS;
|
|
16
|
+
|
|
17
|
+
export interface StrategyModuleContext {
|
|
18
|
+
scope_id: string;
|
|
19
|
+
cycle_id: string;
|
|
20
|
+
forecast_role: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
10
24
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
11
25
|
return value as Record<string, unknown>;
|
|
@@ -37,6 +51,11 @@ function lines(value: unknown): string {
|
|
|
37
51
|
return value.map((item) => typeof item === "string" ? item : JSON.stringify(item)).join("\n");
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
function strategyAction(value: unknown, label: string): StrategyAction {
|
|
55
|
+
if (typeof value === "string" && value in STRATEGY_ACTION_LABELS) return value as StrategyAction;
|
|
56
|
+
throw new Error(`${label} must be one of grow, hold, cut, stop.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
export function projectReviewModule(input: unknown): DashboardModuleBuild {
|
|
41
60
|
const analysis = record(input, "driver_analysis");
|
|
42
61
|
if (analysis.artifact_type !== "driver_analysis") throw new Error("Review publication requires artifact_type=driver_analysis.");
|
|
@@ -93,32 +112,74 @@ export function projectReviewModule(input: unknown): DashboardModuleBuild {
|
|
|
93
112
|
};
|
|
94
113
|
}
|
|
95
114
|
|
|
96
|
-
export function projectStrategyModule(
|
|
115
|
+
export function projectStrategyModule(
|
|
116
|
+
proposalInput: unknown,
|
|
117
|
+
handoffInput: unknown,
|
|
118
|
+
reviewInput: unknown,
|
|
119
|
+
expectedContext: StrategyModuleContext,
|
|
120
|
+
): DashboardModuleBuild {
|
|
97
121
|
const proposal = record(proposalInput, "strategy_proposal");
|
|
98
122
|
const handoff = record(handoffInput, "reviewed_strategy_handoff");
|
|
123
|
+
const review = record(reviewInput, "strategy_review");
|
|
99
124
|
if (proposal.artifact_type !== "strategy_proposal") throw new Error("Strategy publication requires artifact_type=strategy_proposal.");
|
|
100
125
|
if (handoff.kind !== "fpa.reviewed-strategy-handoff") throw new Error("Strategy publication requires kind=fpa.reviewed-strategy-handoff.");
|
|
126
|
+
if (review.artifact_type !== "strategy_review") throw new Error("Strategy publication requires artifact_type=strategy_review.");
|
|
101
127
|
const strategyVersion = requiredString(proposal.strategy_version, "strategy_proposal.strategy_version");
|
|
102
128
|
if (handoff.strategy_version !== strategyVersion || handoff.reviewed_strategy_version !== strategyVersion) {
|
|
103
129
|
throw new Error("Reviewed handoff version does not match the strategy proposal version.");
|
|
104
130
|
}
|
|
105
131
|
if (handoff.status !== "ready") throw new Error("Reviewed strategy handoff is not ready for a human decision.");
|
|
132
|
+
if (review.reviewed_strategy_version !== strategyVersion) throw new Error("Strategy review version does not match the strategy proposal version.");
|
|
133
|
+
if (review.status !== "complete" && review.status !== "complete_with_limits") throw new Error("Strategy review is not complete.");
|
|
134
|
+
if (review.independence_confirmed !== true || handoff.independence_confirmed !== true) throw new Error("Strategy publication requires an independently reviewed proposal.");
|
|
135
|
+
const reviewerIdentity = requiredString(review.reviewer_identity, "strategy_review.reviewer_identity");
|
|
136
|
+
if (handoff.reviewer_identity !== reviewerIdentity) throw new Error("Reviewed handoff reviewer identity does not match the strategy review.");
|
|
137
|
+
if (review.opinion !== "support" && review.opinion !== "support_with_conditions") throw new Error("Strategy review opinion does not support human approval.");
|
|
138
|
+
if (handoff.review_opinion !== review.opinion) throw new Error("Reviewed handoff opinion does not match the strategy review.");
|
|
139
|
+
if (handoff.next_graph !== "fpa-forecast-freeze") throw new Error("Reviewed handoff next_graph must be fpa-forecast-freeze.");
|
|
140
|
+
for (const key of ["scope_id", "cycle_id", "forecast_role"] as const) {
|
|
141
|
+
const expected = requiredString(expectedContext[key], `strategy publication ${key}`);
|
|
142
|
+
if (handoff[key] !== expected) throw new Error(`Reviewed handoff ${key} does not match the publication context.`);
|
|
143
|
+
}
|
|
144
|
+
if (handoff.proposal_fingerprint !== fingerprint(proposal)) throw new Error("Reviewed handoff proposal_fingerprint does not match the strategy proposal.");
|
|
145
|
+
if (handoff.review_fingerprint !== fingerprint(review)) throw new Error("Reviewed handoff review_fingerprint does not match the strategy review.");
|
|
146
|
+
const reviewConditions = array(review.conditions_for_human_approval ?? [], "strategy_review.conditions_for_human_approval");
|
|
147
|
+
const residualRisks = array(review.residual_risks ?? [], "strategy_review.residual_risks");
|
|
148
|
+
if (stableJson(handoff.review_conditions ?? []) !== stableJson(reviewConditions)) throw new Error("Reviewed handoff conditions do not match the strategy review.");
|
|
149
|
+
if (stableJson(handoff.review_residual_risks ?? []) !== stableJson(residualRisks)) throw new Error("Reviewed handoff residual risks do not match the strategy review.");
|
|
106
150
|
const allocations = array(proposal.allocation, "strategy_proposal.allocation");
|
|
107
151
|
const outcomes = record(proposal.expected_outcomes, "strategy_proposal.expected_outcomes");
|
|
108
152
|
const base = record(outcomes.base ?? {}, "strategy_proposal.expected_outcomes.base");
|
|
109
|
-
const
|
|
153
|
+
const parsedAllocations = allocations.map((item, index) => {
|
|
110
154
|
const allocation = record(item, `strategy_proposal.allocation[${index}]`);
|
|
111
|
-
if (typeof allocation.spend !== "number" || !Number.isFinite(allocation.spend)) throw new Error(`strategy_proposal.allocation[${index}].spend must be a finite number.`);
|
|
112
|
-
|
|
113
|
-
|
|
155
|
+
if (typeof allocation.spend !== "number" || !Number.isFinite(allocation.spend) || allocation.spend < 0) throw new Error(`strategy_proposal.allocation[${index}].spend must be a finite non-negative number.`);
|
|
156
|
+
if (typeof allocation.change_from_baseline !== "number" || !Number.isFinite(allocation.change_from_baseline)) throw new Error(`strategy_proposal.allocation[${index}].change_from_baseline must be a finite number.`);
|
|
157
|
+
const action = strategyAction(allocation.action, `strategy_proposal.allocation[${index}].action`);
|
|
158
|
+
const consistent = action === "grow"
|
|
159
|
+
? allocation.change_from_baseline > 0
|
|
160
|
+
: action === "hold"
|
|
161
|
+
? allocation.change_from_baseline === 0
|
|
162
|
+
: action === "cut"
|
|
163
|
+
? allocation.change_from_baseline < 0 && allocation.spend > 0
|
|
164
|
+
: allocation.change_from_baseline <= 0 && allocation.spend === 0;
|
|
165
|
+
if (!consistent) throw new Error(`strategy_proposal.allocation[${index}].action ${action} conflicts with spend and change_from_baseline.`);
|
|
166
|
+
return { allocation, action };
|
|
167
|
+
});
|
|
168
|
+
const totalSpend = parsedAllocations.reduce((sum, { allocation }) => sum + (allocation.spend as number), 0);
|
|
169
|
+
const actionCounts = Object.fromEntries(Object.keys(STRATEGY_ACTION_LABELS).map((action) => [action, 0])) as Record<StrategyAction, number>;
|
|
170
|
+
for (const { action } of parsedAllocations) actionCounts[action] += 1;
|
|
171
|
+
const actionHeadline = (Object.entries(STRATEGY_ACTION_LABELS) as [StrategyAction, string][])
|
|
172
|
+
.map(([action, label]) => `${label} ${actionCounts[action]} 项`)
|
|
173
|
+
.join(";");
|
|
114
174
|
return {
|
|
115
175
|
id: "next-strategy",
|
|
116
176
|
title: "下周期执行策略",
|
|
117
177
|
status: "awaiting_decision",
|
|
118
178
|
source: {
|
|
119
179
|
artifact_type: "reviewed_strategy",
|
|
120
|
-
artifact_fingerprint: fingerprint({ proposal, handoff }),
|
|
180
|
+
artifact_fingerprint: fingerprint({ proposal, review, handoff }),
|
|
121
181
|
proposal_fingerprint: fingerprint(proposal),
|
|
182
|
+
review_fingerprint: fingerprint(review),
|
|
122
183
|
handoff_fingerprint: fingerprint(handoff),
|
|
123
184
|
strategy_version: strategyVersion,
|
|
124
185
|
review_opinion: handoff.review_opinion,
|
|
@@ -137,6 +198,7 @@ export function projectStrategyModule(proposalInput: unknown, handoffInput: unkn
|
|
|
137
198
|
{ metric: "总预算", value: totalSpend.toFixed(2) },
|
|
138
199
|
{ metric: "Base 收入", value: cell(base.revenue) },
|
|
139
200
|
{ metric: "Base ROAS", value: cell(base.roas) },
|
|
201
|
+
{ metric: "执行结论", value: actionHeadline },
|
|
140
202
|
{ metric: "决策理由", value: lines(proposal.decision_rationale) },
|
|
141
203
|
],
|
|
142
204
|
},
|
|
@@ -150,12 +212,13 @@ export function projectStrategyModule(proposalInput: unknown, handoffInput: unkn
|
|
|
150
212
|
label: "预算分配",
|
|
151
213
|
columns: [
|
|
152
214
|
{ key: "app", label: "App" }, { key: "store", label: "商店" }, { key: "channel", label: "渠道" },
|
|
215
|
+
{ key: "action", label: "动作" },
|
|
153
216
|
{ key: "spend", label: "预算", align: "right" }, { key: "change", label: "较基线变化", align: "right" },
|
|
154
217
|
],
|
|
155
|
-
rows:
|
|
156
|
-
const allocation = record(item, `strategy_proposal.allocation[${index}]`);
|
|
218
|
+
rows: parsedAllocations.map(({ allocation, action }) => {
|
|
157
219
|
return {
|
|
158
220
|
app: cell(allocation.app_id), store: cell(allocation.store), channel: cell(allocation.channel_group),
|
|
221
|
+
action: STRATEGY_ACTION_LABELS[action],
|
|
159
222
|
spend: (allocation.spend as number).toFixed(2), change: cell(allocation.change_from_baseline),
|
|
160
223
|
};
|
|
161
224
|
}),
|
|
@@ -182,6 +245,61 @@ export function projectStrategyModule(proposalInput: unknown, handoffInput: unkn
|
|
|
182
245
|
|
|
183
246
|
export function projectForecastModule(forecast: ApprovedCycleForecast, artifactRef: ArtifactRefV2): DashboardModuleBuild {
|
|
184
247
|
const allocationByKey = new Map(forecast.approved_allocation.map((item) => [`${item.app_id}\u0000${item.store}\u0000${item.channel_group}`, item]));
|
|
248
|
+
const quality = forecast.data_quality;
|
|
249
|
+
const summary = quality?.status !== "complete" && forecast.conclusion
|
|
250
|
+
? forecast.conclusion.metrics
|
|
251
|
+
: forecast.consolidated_forecast;
|
|
252
|
+
const qualityWidgets: DashboardModuleBuild["widgets"] = !quality || quality.status === "complete" ? [] : [{
|
|
253
|
+
id: "forecast-data-coverage",
|
|
254
|
+
type: "stat",
|
|
255
|
+
span: "full",
|
|
256
|
+
dataset: "forecast-data-coverage.json",
|
|
257
|
+
data: {
|
|
258
|
+
label: "预测数据覆盖率",
|
|
259
|
+
value: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`,
|
|
260
|
+
description: `可计算预算 ${quality.calculable_spend.toFixed(2)} / 计划预算 ${quality.planned_spend.toFixed(2)};异常切片 ${quality.issue_count} 个。`,
|
|
261
|
+
footnote: forecast.conclusion?.warning ?? "异常切片未参与收入与 ROAS 计算,批准预算未被删除或重分配。",
|
|
262
|
+
progress: { fraction: quality.calculable_spend_pct, label: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`, tone: "warning" },
|
|
263
|
+
},
|
|
264
|
+
}];
|
|
265
|
+
const issueWidgets: DashboardModuleBuild["widgets"] = !quality || quality.issues.length === 0 ? [] : [{
|
|
266
|
+
id: "forecast-data-issues",
|
|
267
|
+
type: "table",
|
|
268
|
+
span: "full",
|
|
269
|
+
dataset: "forecast-data-issues.json",
|
|
270
|
+
data: {
|
|
271
|
+
label: "数据待修复项",
|
|
272
|
+
description: "这些问题不阻断预测结论,但对应切片不参与计算且不可执行。",
|
|
273
|
+
columns: [
|
|
274
|
+
{ key: "slice", label: "App / 商店 / 渠道" },
|
|
275
|
+
{ key: "problem", label: "问题" },
|
|
276
|
+
{ key: "remediation", label: "修复建议" },
|
|
277
|
+
{ key: "owner", label: "负责人" },
|
|
278
|
+
],
|
|
279
|
+
rows: quality.issues.map((issue) => ({
|
|
280
|
+
slice: `${issue.slice.app_id} · ${issue.slice.store} · ${issue.slice.channel_group}`,
|
|
281
|
+
problem: issue.evidence.join("\n"),
|
|
282
|
+
remediation: issue.remediation,
|
|
283
|
+
owner: issue.owner_role,
|
|
284
|
+
})),
|
|
285
|
+
},
|
|
286
|
+
}];
|
|
287
|
+
const generalLimits = forecast.unsupported_metrics.filter((item) => {
|
|
288
|
+
return !(item && typeof item === "object" && !Array.isArray(item)
|
|
289
|
+
&& (item as Record<string, unknown>).reason === "slice_key_not_in_actuals");
|
|
290
|
+
});
|
|
291
|
+
const limitWidgets: DashboardModuleBuild["widgets"] = generalLimits.length === 0 ? [] : [{
|
|
292
|
+
id: "forecast-limitations",
|
|
293
|
+
type: "table",
|
|
294
|
+
span: "full",
|
|
295
|
+
dataset: "forecast-limitations.json",
|
|
296
|
+
data: {
|
|
297
|
+
label: "预测限制提醒",
|
|
298
|
+
description: "以下上游数据或指标限制未阻断预测;不支持的值保持为空,待数据工作人员修复。",
|
|
299
|
+
columns: [{ key: "item", label: "限制与修复信息" }],
|
|
300
|
+
rows: generalLimits.map((item) => ({ item: typeof item === "string" ? item : JSON.stringify(item) })),
|
|
301
|
+
},
|
|
302
|
+
}];
|
|
185
303
|
return {
|
|
186
304
|
id: "next-forecast",
|
|
187
305
|
title: "下周期数据预测",
|
|
@@ -196,6 +314,7 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
196
314
|
data_as_of: forecast.data_as_of,
|
|
197
315
|
},
|
|
198
316
|
widgets: [
|
|
317
|
+
...qualityWidgets,
|
|
199
318
|
{
|
|
200
319
|
id: "forecast-summary",
|
|
201
320
|
type: "table",
|
|
@@ -204,13 +323,13 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
204
323
|
data: {
|
|
205
324
|
label: "预测摘要",
|
|
206
325
|
columns: [{ key: "metric", label: "指标" }, { key: "downside", label: "Downside", align: "right" }, { key: "base", label: "Base", align: "right" }, { key: "upside", label: "Upside", align: "right" }],
|
|
207
|
-
rows: Object.entries(
|
|
326
|
+
rows: Object.entries(summary).map(([metric, scenario]) => ({
|
|
208
327
|
metric,
|
|
209
328
|
downside: cell(scenario.downside),
|
|
210
329
|
base: cell(scenario.base),
|
|
211
330
|
upside: cell(scenario.upside),
|
|
212
331
|
})),
|
|
213
|
-
description: `${forecast.target_period.start_inclusive} → ${forecast.target_period.end_exclusive}`,
|
|
332
|
+
description: `${forecast.target_period.start_inclusive} → ${forecast.target_period.end_exclusive}${quality?.status !== "complete" ? " · 部分口径" : ""}`,
|
|
214
333
|
},
|
|
215
334
|
},
|
|
216
335
|
{
|
|
@@ -236,6 +355,8 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
236
355
|
}),
|
|
237
356
|
},
|
|
238
357
|
},
|
|
358
|
+
...issueWidgets,
|
|
359
|
+
...limitWidgets,
|
|
239
360
|
],
|
|
240
361
|
};
|
|
241
362
|
}
|