@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.
@@ -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 the plan's slice keys against the dimension values ua_spend actually uses. " +
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 !== "complete" || !result.forecast.approval_conditions_satisfied) {
721
- throw new Error("Dashboard publish requires a complete approved forecast with all approval conditions satisfied.");
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 !== "complete" || !next.approval_conditions_satisfied)) {
222
- throw new Error("next_forecast must be complete with approval conditions satisfied.");
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 && (nextSpend?.base === null || nextRevenue?.base === null || nextRoas?.base === null
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 unsupported base metrics; unavailable expectations remain null.");
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 !== "complete" || !artifact.approval_conditions_satisfied) throw new Error(`${path} must be complete and approved.`);
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 !== "complete" || !forecast.approval_conditions_satisfied) {
388
- throw new Error("Dashboard publish requires a complete approved forecast with all approval conditions satisfied.");
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 sliceKeyMismatch = detectSliceKeyMismatch(forecast.approved_allocation, actuals);
177
- if (sliceKeyMismatch) build.warnings.push(sliceKeyMismatch);
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(forecast.consolidated_forecast).map(([metric, scenario]) => ({
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" || graph === "fpa-forecast-freeze"
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" || graph === "fpa-forecast-freeze") {
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."