@viccydev/pi-fpa 0.8.1 → 0.9.1
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 +3 -3
- package/extensions/fpa-artifacts/compose.ts +186 -47
- package/extensions/fpa-artifacts/contracts.ts +202 -2
- package/extensions/fpa-artifacts/finalize.ts +99 -0
- package/extensions/fpa-artifacts/forecast-quality.ts +138 -0
- package/extensions/fpa-artifacts/index.ts +36 -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 +2 -2
- package/extensions/fpa-dashboard/projector.ts +58 -1
- package/extensions/fpa-dashboard/service.ts +13 -2
- package/extensions/fpa-dashboard/stage-projector.ts +60 -2
- package/extensions/fpa-routing-guard/index.ts +8 -3
- package/graphs/fpa-forecast-freeze.json +35 -39
- package/package.json +3 -3
- package/skills/fpa-apply-core-rules/SKILL.md +4 -2
- package/skills/fpa-apply-core-rules/references/core-rules.md +5 -2
- package/skills/fpa-diagnose-actuals/references/artifact-contract.md +2 -0
- package/skills/fpa-forecast-approved-strategy/SKILL.md +23 -17
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +34 -6
- package/skills/fpa-refresh-dashboard/SKILL.md +15 -10
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { composeApprovedForecast, type ComposeOptions, type ComposeResult } from "./compose.ts";
|
|
2
|
+
import {
|
|
3
|
+
commitArtifact,
|
|
4
|
+
type ArtifactCommitContext,
|
|
5
|
+
type CommitArtifactResult,
|
|
6
|
+
type ForecastRole,
|
|
7
|
+
} from "./store.ts";
|
|
8
|
+
|
|
9
|
+
export interface ForecastFinalizeContext {
|
|
10
|
+
scope_id: string;
|
|
11
|
+
cycle_id: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ForecastFinalizeResult extends ComposeResult {
|
|
15
|
+
forecastRole: Extract<ForecastRole, "eac" | "next_plan" | "backtest">;
|
|
16
|
+
committed: CommitArtifactResult;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function withoutCallerFrozenAt(plan: unknown): unknown {
|
|
20
|
+
if (plan === null || typeof plan !== "object" || Array.isArray(plan)) return plan;
|
|
21
|
+
const { frozen_at: _ignored, ...decisionPlan } = plan as Record<string, unknown>;
|
|
22
|
+
return decisionPlan;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The forecast-freeze workflow owns next-cycle planning, in-period reforecast,
|
|
27
|
+
* and historical reruns. Its ledger role follows from when the immutable body
|
|
28
|
+
* is frozen relative to its own target period; callers do not translate a
|
|
29
|
+
* business label into storage vocabulary.
|
|
30
|
+
*/
|
|
31
|
+
export function deriveFreezeForecastRole(
|
|
32
|
+
targetPeriod: { start_inclusive: string; end_exclusive: string },
|
|
33
|
+
frozenAt: string,
|
|
34
|
+
): ForecastFinalizeResult["forecastRole"] {
|
|
35
|
+
const start = Date.parse(targetPeriod.start_inclusive);
|
|
36
|
+
const end = Date.parse(targetPeriod.end_exclusive);
|
|
37
|
+
const frozen = Date.parse(frozenAt);
|
|
38
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(frozen) || end <= start) {
|
|
39
|
+
throw new Error("Cannot derive forecast role from an invalid target period or frozen_at timestamp.");
|
|
40
|
+
}
|
|
41
|
+
if (frozen >= end) return "backtest";
|
|
42
|
+
if (frozen >= start) return "eac";
|
|
43
|
+
return "next_plan";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function finalizeApprovedForecast(
|
|
47
|
+
projectRoot: string,
|
|
48
|
+
plan: unknown,
|
|
49
|
+
context: ForecastFinalizeContext,
|
|
50
|
+
options: ComposeOptions = {},
|
|
51
|
+
): Promise<ForecastFinalizeResult> {
|
|
52
|
+
const composed = await composeApprovedForecast(withoutCallerFrozenAt(plan), options);
|
|
53
|
+
if (composed.artifact.status === "blocked") {
|
|
54
|
+
throw new Error("A blocked forecast plan cannot be finalized or committed.");
|
|
55
|
+
}
|
|
56
|
+
const forecastRole = deriveFreezeForecastRole(
|
|
57
|
+
composed.artifact.target_period,
|
|
58
|
+
composed.artifact.frozen_at,
|
|
59
|
+
);
|
|
60
|
+
const commitContext: ArtifactCommitContext = {
|
|
61
|
+
scope_id: context.scope_id,
|
|
62
|
+
cycle_id: context.cycle_id,
|
|
63
|
+
forecast_role: forecastRole,
|
|
64
|
+
};
|
|
65
|
+
const committed = await commitArtifact(projectRoot, composed.artifact, { context: commitContext });
|
|
66
|
+
return { ...composed, forecastRole, committed };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Keep the Graph result actionable without returning the full canonical artifact. */
|
|
70
|
+
export function forecastFinalizeDetails(result: ForecastFinalizeResult): Record<string, unknown> {
|
|
71
|
+
const artifactRef = result.committed.artifactRef;
|
|
72
|
+
const dashboardHandoff = result.forecastRole === "backtest"
|
|
73
|
+
? {
|
|
74
|
+
status: "not_applicable",
|
|
75
|
+
reason: "backtest forecasts are immutable evidence and are not enqueued for an operating dashboard",
|
|
76
|
+
}
|
|
77
|
+
: artifactRef
|
|
78
|
+
? {
|
|
79
|
+
status: "deferred",
|
|
80
|
+
tool: "fpa_dashboard_refresh_queue",
|
|
81
|
+
action: "enqueue_artifact",
|
|
82
|
+
artifact_ref: artifactRef,
|
|
83
|
+
}
|
|
84
|
+
: undefined;
|
|
85
|
+
return {
|
|
86
|
+
status: "committed",
|
|
87
|
+
artifact_type: result.committed.artifactType,
|
|
88
|
+
forecast_role: result.forecastRole,
|
|
89
|
+
immutable_fingerprint: result.committed.fingerprint,
|
|
90
|
+
path: result.committed.path,
|
|
91
|
+
ledger_status: result.committed.ledgerStatus,
|
|
92
|
+
diagnostics: result.diagnostics as unknown as Record<string, unknown>,
|
|
93
|
+
data_quality: result.artifact.data_quality,
|
|
94
|
+
unsupported_metrics: result.artifact.unsupported_metrics,
|
|
95
|
+
conclusion: result.artifact.conclusion,
|
|
96
|
+
...(artifactRef ? { artifact_ref: artifactRef } : {}),
|
|
97
|
+
...(dashboardHandoff ? { dashboard_handoff: dashboardHandoff } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
|
|
6
6
|
import { composeApprovedForecast } from "./compose.ts";
|
|
7
|
+
import { finalizeApprovedForecast, forecastFinalizeDetails } from "./finalize.ts";
|
|
7
8
|
import {
|
|
8
9
|
commitArtifact,
|
|
9
10
|
commitArtifactFromPath,
|
|
@@ -142,7 +143,8 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
142
143
|
label: "Compose Approved Forecast",
|
|
143
144
|
description:
|
|
144
145
|
"Derive a complete, self-consistent approved_cycle_forecast from a compact plan file holding only the approved allocation and its per-slice ROAS assumptions. " +
|
|
145
|
-
"Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks
|
|
146
|
+
"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. " +
|
|
147
|
+
"Data-quality mismatches are isolated into complete_with_limits with coverage and repair diagnostics; approved allocation is never dropped or renormalized. " +
|
|
146
148
|
"Returns the artifact for fpa_artifact_commit to freeze; writes nothing.",
|
|
147
149
|
promptSnippet: "Derive a complete approved forecast from a compact plan",
|
|
148
150
|
promptGuidelines: [
|
|
@@ -169,4 +171,37 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
169
171
|
});
|
|
170
172
|
},
|
|
171
173
|
});
|
|
174
|
+
|
|
175
|
+
pi.registerTool({
|
|
176
|
+
name: "fpa_forecast_finalize",
|
|
177
|
+
label: "Finalize Approved Forecast",
|
|
178
|
+
description:
|
|
179
|
+
"Compose, validate, assign the lifecycle role, and atomically commit an approved forecast from its compact plan. " +
|
|
180
|
+
"The role is derived from frozen_at relative to the target period: future is next_plan, in-period is eac, and ended periods are backtest. " +
|
|
181
|
+
"Use this as the single mutation seam for fpa-forecast-freeze; callers do not pass or translate forecast_role.",
|
|
182
|
+
promptSnippet: "Finalize and freeze a compact approved forecast plan",
|
|
183
|
+
promptGuidelines: [
|
|
184
|
+
"Pass only the compact plan path and immutable scope/cycle identity; forecast_role is derived deterministically.",
|
|
185
|
+
"A historical target is committed as backtest evidence and never promoted as an operating forecast.",
|
|
186
|
+
],
|
|
187
|
+
parameters: Type.Object({
|
|
188
|
+
plan_path: Type.String({
|
|
189
|
+
minLength: 1,
|
|
190
|
+
description: "Project-relative path to the compact forecast plan JSON file.",
|
|
191
|
+
}),
|
|
192
|
+
scope_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
193
|
+
cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
194
|
+
}, { additionalProperties: false }),
|
|
195
|
+
executionMode: "sequential",
|
|
196
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
197
|
+
const plan = await readProjectJsonFile(ctx.cwd, params.plan_path, "plan_path");
|
|
198
|
+
const finalized = await finalizeApprovedForecast(
|
|
199
|
+
ctx.cwd,
|
|
200
|
+
plan,
|
|
201
|
+
{ scope_id: params.scope_id, cycle_id: params.cycle_id },
|
|
202
|
+
{ signal },
|
|
203
|
+
);
|
|
204
|
+
return toolResult(forecastFinalizeDetails(finalized));
|
|
205
|
+
},
|
|
206
|
+
});
|
|
172
207
|
}
|
|
@@ -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.");
|
|
@@ -384,8 +384,8 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
if (sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${sliceKeyMismatch}`);
|
|
387
|
-
if (forecast.status
|
|
388
|
-
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.");
|
|
389
389
|
}
|
|
390
390
|
if (!params.expected_preview_fingerprint) throw new Error("Publish requires expected_preview_fingerprint from a preceding preview.");
|
|
391
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
|
}
|
|
@@ -245,6 +245,61 @@ export function projectStrategyModule(
|
|
|
245
245
|
|
|
246
246
|
export function projectForecastModule(forecast: ApprovedCycleForecast, artifactRef: ArtifactRefV2): DashboardModuleBuild {
|
|
247
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
|
+
}];
|
|
248
303
|
return {
|
|
249
304
|
id: "next-forecast",
|
|
250
305
|
title: "下周期数据预测",
|
|
@@ -259,6 +314,7 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
259
314
|
data_as_of: forecast.data_as_of,
|
|
260
315
|
},
|
|
261
316
|
widgets: [
|
|
317
|
+
...qualityWidgets,
|
|
262
318
|
{
|
|
263
319
|
id: "forecast-summary",
|
|
264
320
|
type: "table",
|
|
@@ -267,13 +323,13 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
267
323
|
data: {
|
|
268
324
|
label: "预测摘要",
|
|
269
325
|
columns: [{ key: "metric", label: "指标" }, { key: "downside", label: "Downside", align: "right" }, { key: "base", label: "Base", align: "right" }, { key: "upside", label: "Upside", align: "right" }],
|
|
270
|
-
rows: Object.entries(
|
|
326
|
+
rows: Object.entries(summary).map(([metric, scenario]) => ({
|
|
271
327
|
metric,
|
|
272
328
|
downside: cell(scenario.downside),
|
|
273
329
|
base: cell(scenario.base),
|
|
274
330
|
upside: cell(scenario.upside),
|
|
275
331
|
})),
|
|
276
|
-
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" ? " · 部分口径" : ""}`,
|
|
277
333
|
},
|
|
278
334
|
},
|
|
279
335
|
{
|
|
@@ -299,6 +355,8 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
|
|
|
299
355
|
}),
|
|
300
356
|
},
|
|
301
357
|
},
|
|
358
|
+
...issueWidgets,
|
|
359
|
+
...limitWidgets,
|
|
302
360
|
],
|
|
303
361
|
};
|
|
304
362
|
}
|
|
@@ -168,9 +168,11 @@ function graphContextError(event: ToolCallEvent, graph: FpaGraph): string | unde
|
|
|
168
168
|
return "graph_run requires a context object whose values are strings.";
|
|
169
169
|
}
|
|
170
170
|
const values = context as Record<string, unknown>;
|
|
171
|
-
const required = graph === "fpa-strategy-planning"
|
|
171
|
+
const required = graph === "fpa-strategy-planning"
|
|
172
172
|
? ["scope_id", "cycle_id", "forecast_role"]
|
|
173
|
-
:
|
|
173
|
+
: graph === "fpa-forecast-freeze"
|
|
174
|
+
? ["scope_id", "cycle_id"]
|
|
175
|
+
: [];
|
|
174
176
|
const missing = required.filter((key) => !(key in values) || (typeof values[key] === "string" && values[key].trim().length === 0));
|
|
175
177
|
if (missing.length > 0) return `graph_run requires non-empty string context keys: ${missing.join(", ")}.`;
|
|
176
178
|
const nonStrings = Object.entries(values)
|
|
@@ -188,9 +190,12 @@ function routingInstruction(graph: FpaGraph, resumeCompletedGraph = false): stri
|
|
|
188
190
|
"Call graph_list first, then graph_run with that exact graph. All graph_run context values must be strings; never pass arrays, objects, numbers, booleans, or null.",
|
|
189
191
|
"Graph nodes own business phase execution. After a successful Graph handoff, only the main Agent may call the matching fpa_dashboard_publish_* tool; Graphs must never publish dashboard data.",
|
|
190
192
|
];
|
|
191
|
-
if (graph === "fpa-strategy-planning"
|
|
193
|
+
if (graph === "fpa-strategy-planning") {
|
|
192
194
|
instructions.push("Provide the immutable non-empty string context keys scope_id, cycle_id, and forecast_role. Put structured planning details in goal, not context.");
|
|
193
195
|
}
|
|
196
|
+
if (graph === "fpa-forecast-freeze") {
|
|
197
|
+
instructions.push("Provide only the immutable non-empty string context keys scope_id and cycle_id. The ledger forecast_role is derived from the frozen forecast target period; do not translate or pass a business role label.");
|
|
198
|
+
}
|
|
194
199
|
if (graph === "fpa-strategy-planning") {
|
|
195
200
|
instructions.push(resumeCompletedGraph
|
|
196
201
|
? "A completed combined Graph handoff was restored from this session. Continue modular publication from its persisted artifacts; do not rerun the Graph unless those artifacts are unavailable or the user changed the planning requirements. Publish period-review before next-strategy."
|