Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 | 178x 178x 89x 99x 37x 37x 16x 21x 7x 7x 20x 20x 20x 80x 11x 11x 11x 35x 35x 35x 34x 17x 17x 17x 17x 17x 16x 16x 16x 16x 16x 16x 176x 176x 176x 141x 35x 35x 35x 16x 16x 176x 20x 20x 20x 1x 20x 176x | /**
* @module data-transformers/load-economic-context
* @description Loader for the per-article `economic-data.json` artefact
* produced by agentic workflows during the pre-article analysis phase.
*
* The loader bridges the data produced by workflow agents (which call
* the World Bank / SCB MCP tools and the in-repo IMF TypeScript client
* via `tsx scripts/imf-fetch.ts`) and the HTML renderer in
* `content-generators/economic-dashboard-section.ts`. When the JSON file
* exists and contains `dataPoints` with at least one entry, the renderer
* emits real `data-chart-config` Chart.js canvases; when it is missing or
* empty the caller should fail the build (see
* `scripts/validate-economic-context.ts`) so the placeholder bullet list
* can never ship to production.
*
* Expected file path:
* analysis/daily/YYYY-MM-DD/{slug}/economic-data.json
* where `{slug}` is mapped via `ARTICLE_TYPE_TO_ANALYSIS_SUBFOLDER`
* (e.g. committee-reports → committeeReports).
*
* Schema:
* analysis/schemas/economic-data.schema.json
*
* Schema v2.0 (2026-04-20) — additive:
* - `source.imf[]` accepted alongside `source.worldBank[]` / `source.scb[]`
* - `dataPoints[].provider` ('worldBank' | 'imf' | 'scb') — defaults to 'worldBank' when omitted
* - `dataPoints[].projection` boolean — marks forecast values (IMF WEO/FM)
* - `dataPoints[].projectionVintage` string — vintage tag (e.g. 'WEO-2026-04')
* v1 artefacts are still accepted unchanged; the loader fills defaults.
*
* @author Hack23 AB
* @license Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { EconomicDataPoint } from './content-generators/economic-dashboard-section.js';
import { ARTICLE_TYPE_TO_ANALYSIS_SUBFOLDER } from '../analysis-references.js';
/**
* Attribution source list as it appears in the on-disk
* `economic-data.json` artefact. `imf` is optional for schema v1
* back-compat; schema v2+ writers should emit it (the loader always
* normalises it to an array in {@link EconomicContextSource}).
*/
export interface EconomicContextSourceFile {
/** World Bank indicator IDs actually queried (e.g. `NY.GDP.MKTP.KD.ZG`). */
worldBank: string[];
/** SCB table IDs actually queried (e.g. `TAB1291`). */
scb: string[];
/**
* IMF citation strings actually queried (e.g. `WEO:NGDP_RPCH`,
* `FM:GGXWDG_NGDP`). Absent in schema v1 artefacts.
*/
imf?: string[];
}
/**
* Attribution source list as surfaced by {@link loadEconomicContext}.
* The loader always populates `imf` (empty array for v1 artefacts) so
* downstream consumers never need to null-check.
*/
export interface EconomicContextSource {
/** World Bank indicator IDs actually queried (e.g. `NY.GDP.MKTP.KD.ZG`). */
worldBank: string[];
/** SCB table IDs actually queried (e.g. `TAB1291`). */
scb: string[];
/**
* IMF citation strings actually queried (e.g. `WEO:NGDP_RPCH`,
* `FM:GGXWDG_NGDP`). Always populated as an array by the loader —
* may be empty on v1 files.
*/
imf: string[];
}
/** Schema v2+ provider tag on each data point. */
export type EconomicDataProvider = 'worldBank' | 'imf' | 'scb';
/** Schema v2+ enriched data point adding provider + projection metadata. */
export interface EnrichedEconomicDataPoint extends EconomicDataPoint {
/** Provider that supplied the value. Defaults to 'worldBank' for v1 artefacts. */
provider: EconomicDataProvider;
/** True when the value is a forecast (IMF WEO/FM). */
projection: boolean;
/** Vintage tag of the projection release (e.g. 'WEO-2026-04'). Present only when projection=true. */
projectionVintage?: string;
}
/**
* Parsed shape of `economic-data.json`. The agent writes this file during
* the pre-article analysis phase and the renderer reads it to decide
* whether to emit real charts or fail the quality gate.
*/
export interface EconomicContextFile {
/** Version of the contract this file was produced against ('1.0' or '2.0'). */
version?: string;
/** Article type slug (e.g. `committee-reports`). */
articleType?: string;
/** ISO date (YYYY-MM-DD) the file was produced for. */
date?: string;
/** Policy domains detected from the source documents. */
policyDomains: string[];
/**
* Data points driving Chart.js canvases. In schema v2 each point MAY
* carry a `provider` / `projection` / `projectionVintage` triple; the
* loader preserves them when present and defaults missing values for
* back-compat with v1 artefacts.
*/
dataPoints: EconomicDataPoint[];
/**
* AI-authored commentary paragraph. MUST reference 2–3 concrete
* values from `dataPoints`. 2–4 sentences.
*/
commentary: string;
/** Attribution sources for the footer / compliance gate. */
source: EconomicContextSourceFile;
/**
* Explicit opt-out for pure-process article types (e.g. realtime
* monitor stories about parliamentary procedure). When `true`,
* the renderer omits the economic dashboard section entirely and
* the validator treats the slug as exempt.
*
* The validator enforces an allow-list so `skip: true` is only
* honoured for specific article types.
*/
skip?: boolean;
/** Free-form note explaining a `skip: true` decision. */
skipReason?: string;
}
/**
* Extra data passed to the economic dashboard renderer, filled in from
* the `economic-data.json` artefact when it exists.
*/
export interface LoadedEconomicContext {
/** Contract version the artefact was produced against ('1.0' | '2.0'). */
version: string;
/** Policy domains to feed into `findIndicatorsForDomains`. */
policyDomains: string[];
/**
* Data points driving real Chart.js canvases. Keeps the v1 shape for
* existing consumers; call `enrichedDataPoints` to see provider /
* projection metadata.
*/
dataPoints: EconomicDataPoint[];
/**
* Data points with v2 provider / projection metadata expanded. Always
* available — for v1 artefacts every point is `{provider: 'worldBank',
* projection: false}`.
*/
enrichedDataPoints: EnrichedEconomicDataPoint[];
/** AI commentary used as the dashboard section `summary`. */
commentary: string;
/** Attribution sources. */
source: EconomicContextSource;
/** Relative filesystem path the context was loaded from (for logging). */
sourcePath: string;
/** Whether the workflow explicitly opted out of economic context. */
skip: boolean;
/** Free-form skip reason, when `skip` is `true`. */
skipReason?: string;
}
/**
* Compute the filesystem path for an article's `economic-data.json`.
*
* @param date - Article date in `YYYY-MM-DD` format
* @param articleType - Article type slug (kebab-case), e.g. `committee-reports`
* @param rootDir - Repository root (defaults to CWD). Injectable for tests.
* @returns Absolute or CWD-relative path. The file may or may not exist.
*/
export function economicDataPath(
date: string,
articleType: string,
rootDir: string = process.cwd(),
): string {
const subfolder = ARTICLE_TYPE_TO_ANALYSIS_SUBFOLDER[articleType] ?? articleType;
return path.join(rootDir, 'analysis', 'daily', date, subfolder, 'economic-data.json');
}
/**
* Helpers for the type guard below.
*/
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
}
/**
* Type guard for a single `EconomicDataPoint`. Each point drives Chart.js
* rendering so a malformed shape here surfaces as a blank/broken chart
* downstream — validate aggressively.
*
* Schema v2 additions (`provider`, `projection`, `projectionVintage`) are
* accepted but optional — when present their types are validated.
*/
function isEconomicDataPoint(value: unknown): value is EconomicDataPoint {
Iif (!isRecord(value)) return false;
if (
typeof value['countryCode'] !== 'string' ||
typeof value['countryName'] !== 'string' ||
typeof value['indicatorId'] !== 'string' ||
typeof value['date'] !== 'string' ||
typeof value['value'] !== 'number' ||
!Number.isFinite(value['value'])
) {
return false;
}
// Schema v2 optional fields: only reject when present with the wrong type.
if ('provider' in value && value['provider'] !== undefined) {
const p = value['provider'];
if (p !== 'worldBank' && p !== 'imf' && p !== 'scb') return false;
}
Iif ('projection' in value && value['projection'] !== undefined && typeof value['projection'] !== 'boolean') {
return false;
}
Iif ('projectionVintage' in value && value['projectionVintage'] !== undefined && typeof value['projectionVintage'] !== 'string') {
return false;
}
return true;
}
/**
* Helper for validating optional fields on `EconomicContextFile`.
* Accepts when the key is absent or undefined; only rejects when it is
* present with the wrong runtime type.
*/
function isOptionalFieldOfType(
obj: Record<string, unknown>,
key: string,
expected: 'string' | 'boolean',
): boolean {
if (!(key in obj)) return true;
const value = obj[key];
Iif (value === undefined) return true;
return typeof value === expected;
}
/**
* Type guard for the raw parsed JSON.
* Validates both the top-level shape AND the element shapes for
* `dataPoints`, `policyDomains`, and `source.*`, plus optional-field
* types (`version`, `articleType`, `date`, `skip`, `skipReason`).
*
* Schema v2 addition: `source.imf[]` is accepted but optional for v1
* back-compat. When present it must be a string array.
*/
function isEconomicContextFile(value: unknown): value is EconomicContextFile {
Iif (!isRecord(value)) return false;
const v = value;
if (!isStringArray(v['policyDomains'])) return false;
if (!Array.isArray(v['dataPoints']) || !v['dataPoints'].every(isEconomicDataPoint)) return false;
Iif (typeof v['commentary'] !== 'string') return false;
Iif (!isRecord(v['source'])) return false;
const s = v['source'];
Iif (!isStringArray(s['worldBank']) || !isStringArray(s['scb'])) return false;
// Schema v2 optional: imf[] string array if present.
if ('imf' in s && s['imf'] !== undefined && !isStringArray(s['imf'])) return false;
// Optional fields: present only when typed correctly.
Iif (!isOptionalFieldOfType(v, 'version', 'string')) return false;
Iif (!isOptionalFieldOfType(v, 'articleType', 'string')) return false;
Iif (!isOptionalFieldOfType(v, 'date', 'string')) return false;
Iif (!isOptionalFieldOfType(v, 'skip', 'boolean')) return false;
Iif (!isOptionalFieldOfType(v, 'skipReason', 'string')) return false;
return true;
}
/**
* Load `economic-data.json` for the given article, if it exists and is
* well-formed. Returns `null` when the file is absent, malformed, or
* parses to an empty structure — callers interpret a null return as
* "workflow did not supply economic context" and should fail the
* quality gate when the article type requires it.
*
* This function is intentionally synchronous so it can be called inside
* the existing synchronous visualization builder without propagating
* async signatures through 5+ callers.
*
* @param date - Article date (`YYYY-MM-DD`)
* @param articleType - Article type slug (`committee-reports` etc.)
* @param rootDir - Optional repo root (defaults to `process.cwd()`)
*/
export function loadEconomicContext(
date: string,
articleType: string,
rootDir: string = process.cwd(),
): LoadedEconomicContext | null {
const filePath = economicDataPath(date, articleType, rootDir);
let raw: string;
try {
raw = fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isEconomicContextFile(parsed)) return null;
const file = parsed;
// Source object — always populate `imf` as an array (empty on v1 back-compat).
const imfSources = file.source.imf ? [...file.source.imf] : [];
// Enriched data points — default missing provider/projection for v1.
const enrichedDataPoints: EnrichedEconomicDataPoint[] = file.dataPoints.map((dp) => {
const raw = dp as EconomicDataPoint & {
provider?: EconomicDataProvider;
projection?: boolean;
projectionVintage?: string;
};
const enriched: EnrichedEconomicDataPoint = {
...dp,
provider: raw.provider ?? 'worldBank',
projection: raw.projection === true,
};
if (typeof raw.projectionVintage === 'string' && raw.projectionVintage.length > 0) {
enriched.projectionVintage = raw.projectionVintage;
}
return enriched;
});
return {
version: typeof file.version === 'string' ? file.version : '1.0',
policyDomains: [...file.policyDomains],
dataPoints: [...file.dataPoints],
enrichedDataPoints,
commentary: file.commentary,
source: {
worldBank: [...file.source.worldBank],
scb: [...file.source.scb],
imf: imfSources,
},
sourcePath: path.relative(rootDir, filePath) || filePath,
skip: file.skip === true,
skipReason: typeof file.skipReason === 'string' ? file.skipReason : undefined,
};
}
|