BID · Console
Baseline · Intelligence · Decision
scripts/ask.ts 4,877 bytes · typescript
/**
 * Free-form prompt entry point for the Baseline pillar.
 *
 *   npm run ask -- "How much revenue did ACME and Globex report in 2024?"
 *
 * The prompt is interpreted into a structured JobRequest (entities,
 * metric, period) using simple keyword matching against the entities
 * the mock retrieval layer knows about. The full Baseline pipeline
 * runs against that JobRequest and writes a fresh JSON + HTML report
 * under output/.
 */

import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { runAnalysis } from '../src/orchestrator.js';
import type { JobRequest } from '../src/types.js';
import { renderReport } from './render-report.js';
import { renderFlow } from './render-flow.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUTPUT_DIR = path.resolve(__dirname, '..', 'output');

/** Entities the mock retrieval layer can return raw payloads for. */
const KNOWN_ENTITIES: { id: string; aliases: string[] }[] = [
  { id: 'ACME', aliases: ['ACME Corp', 'Acme'] },
  { id: 'GLOBEX', aliases: ['Globex SE', 'Globex'] },
  { id: 'INITECH', aliases: ['Initech, Inc.', 'Initech'] },
];

/** Metric keywords → canonical metric key. */
const METRIC_KEYWORDS: { key: string; definition: string; keywords: string[] }[] = [
  {
    key: 'revenue',
    definition: 'Total revenue from operations for the period',
    keywords: ['revenue', 'sales', 'net revenues', 'total revenue', 'top line'],
  },
];

function buildJobRequest(prompt: string): JobRequest {
  const lower = prompt.toLowerCase();

  const matchedEntities = KNOWN_ENTITIES.filter(e =>
    [e.id, ...e.aliases].some(a => lower.includes(a.toLowerCase())),
  );
  const entities = matchedEntities.length > 0 ? matchedEntities : KNOWN_ENTITIES;

  const matchedMetric =
    METRIC_KEYWORDS.find(m => m.keywords.some(k => lower.includes(k))) ?? METRIC_KEYWORDS[0]!;

  const yearMatch = prompt.match(/\b(19|20)\d{2}\b/);
  const period = yearMatch ? `FY-${yearMatch[0]}` : 'FY-2024';

  return {
    analysisId: `ask-${Date.now().toString(36)}`,
    question: prompt,
    entities,
    targetMetrics: [
      { key: matchedMetric.key, definition: matchedMetric.definition, unit: 'USD_MM' },
    ],
    sources: ['mock'],
    period,
    seedMappings: [
      { sourceLabel: 'Net revenues', targetKey: 'revenue' },
      { sourceLabel: 'Total revenue', targetKey: 'revenue' },
    ],
  };
}

async function main(): Promise<void> {
  const prompt = process.argv.slice(2).join(' ').trim();
  if (!prompt) {
    // eslint-disable-next-line no-console
    console.error('Usage: npm run ask -- "<your question>"');
    process.exit(1);
  }
  const job = buildJobRequest(prompt);
  // eslint-disable-next-line no-console
  console.log('Prompt:', JSON.stringify(prompt));
  // eslint-disable-next-line no-console
  console.log('Interpreted JobRequest:');
  // eslint-disable-next-line no-console
  console.log(
    `  entities: ${job.entities.map(e => e.id).join(', ')}\n` +
      `  metric:   ${job.targetMetrics[0]?.key}\n` +
      `  period:   ${job.period}\n` +
      `  sources:  ${job.sources.join(', ')}`,
  );

  const result = await runAnalysis(job);

  await mkdir(OUTPUT_DIR, { recursive: true });
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
  const file = path.join(OUTPUT_DIR, `ask-${stamp}.json`);
  const audit = { ...result, jobRequest: job };
  await writeFile(file, JSON.stringify(audit, null, 2), 'utf8');
  const htmlPath = await renderReport(file);
  const flowPath = await renderFlow(file);
  // eslint-disable-next-line no-console
  console.log(`\nAudit JSON:    ${path.relative(process.cwd(), file)}`);
  // eslint-disable-next-line no-console
  console.log(`HTML report:   ${path.relative(process.cwd(), htmlPath)}`);
  // eslint-disable-next-line no-console
  console.log(`Flow page:     ${path.relative(process.cwd(), flowPath)}`);

  if (result.ok && result.finalHandoff) {
    const recs = (result.finalHandoff.payload as { records?: { canonicalEntity: string; canonicalMetric: string; period: string; value: number | null; unit: string }[] }).records ?? [];
    // eslint-disable-next-line no-console
    console.log('\nAnswer:');
    for (const r of recs) {
      // eslint-disable-next-line no-console
      console.log(
        `  ${r.canonicalEntity}  ${r.canonicalMetric} ${r.period}: ${r.value === null ? '—' : r.value.toLocaleString()} ${r.unit}`,
      );
    }
  } else if (result.failure) {
    // eslint-disable-next-line no-console
    console.log('\nRun did not complete:');
    // eslint-disable-next-line no-console
    console.log(`  ${result.failure.category}: ${result.failure.reason}`);
    process.exitCode = 1;
  }
}

main().catch(err => {
  // eslint-disable-next-line no-console
  console.error('Unhandled error:', err);
  process.exit(1);
});