@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.
Files changed (29) hide show
  1. package/README.md +15 -4
  2. package/extensions/fpa-artifacts/compose.ts +156 -42
  3. package/extensions/fpa-artifacts/contracts.ts +202 -2
  4. package/extensions/fpa-artifacts/forecast-quality.ts +138 -0
  5. package/extensions/fpa-artifacts/index.ts +24 -1
  6. package/extensions/fpa-dashboard/coordinator.ts +2 -2
  7. package/extensions/fpa-dashboard/cycle-operating-projection.ts +5 -4
  8. package/extensions/fpa-dashboard/forward-outlook.ts +5 -2
  9. package/extensions/fpa-dashboard/index.ts +12 -3
  10. package/extensions/fpa-dashboard/projector.ts +58 -1
  11. package/extensions/fpa-dashboard/service.ts +13 -2
  12. package/extensions/fpa-dashboard/stage-projector.ts +131 -10
  13. package/extensions/fpa-routing-guard/graph-installer.ts +234 -0
  14. package/extensions/fpa-routing-guard/index.ts +114 -28
  15. package/graphs/fpa-forecast-freeze.json +103 -7
  16. package/graphs/fpa-strategy-planning.json +159 -0
  17. package/package.json +2 -2
  18. package/prompts/fpa-plan-cycle.md +12 -12
  19. package/skills/fpa-apply-core-rules/SKILL.md +8 -8
  20. package/skills/fpa-apply-core-rules/references/core-rules.md +11 -13
  21. package/skills/fpa-diagnose-actuals/references/artifact-contract.md +2 -0
  22. package/skills/fpa-forecast-approved-strategy/SKILL.md +12 -0
  23. package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +26 -1
  24. package/skills/fpa-recommend-strategy/SKILL.md +1 -1
  25. package/skills/fpa-recommend-strategy/references/artifact-contract.md +2 -1
  26. package/skills/fpa-refresh-dashboard/SKILL.md +7 -4
  27. package/skills/fpa-review-strategy/references/artifact-contract.md +22 -0
  28. package/graphs/fpa-period-analysis.json +0 -10
  29. 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 the plan's slice keys against the dimension values ua_spend actually uses. " +
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 !== "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.");
@@ -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 !== "complete" || !forecast.approval_conditions_satisfied) {
379
- 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.");
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 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
  }
@@ -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(proposalInput: unknown, handoffInput: unknown): DashboardModuleBuild {
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 totalSpend = allocations.reduce((sum, item, index) => {
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
- return sum + allocation.spend;
113
- }, 0);
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: allocations.map((item, index) => {
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(forecast.consolidated_forecast).map(([metric, scenario]) => ({
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
  }