Ctrl K
ComunidadGuías…

scripts/_forecast-subject.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// What a forecast's region is called in text, shared by the judged resolver
// (news evidence) and the market anchor matcher (market titles) so both lanes
// agree on what a subject is (#8990, #9010).

import { readFileSync } from 'node:fs';

// What a headline calls each judged subject: every country and every region
// label the forecast emitter can produce, generated by
// scripts/build-judged-subject-terms.mjs (#8990). Loaded on first use so the
// generator can import this module before the table exists.
const JUDGED_SUBJECT_TABLE_URL = new URL('./shared/judged-subject-terms.json', import.meta.url);
let judgedSubjectTable;
function loadJudgedSubjectTable() {
  judgedSubjectTable ??= JSON.parse(readFileSync(JUDGED_SUBJECT_TABLE_URL, 'utf8'));
  return judgedSubjectTable;
}

/**
 * How a region label resolves against the subject table: a named region, a
 * country, a combination (`DR Congo (Zaire)`, `Israel/Gaza`), `none` for an
 * empty or global label, or `fallback` when nothing matches and the bare label
 * is all there is.
 */
export function judgedSubjectKind(label, table = loadJudgedSubjectTable()) {
  return resolveJudgedSubject(label, table).kind;
}

function resolveJudgedSubject(label, table) {
  const key = normalizeSubjectText(label);
  if (!key || key === 'global') return { kind: 'none', terms: [], countries: [], exclusions: [] };
  const direct = resolveSubjectKey(key, table);
  if (direct) return direct;
  const parts = String(label).split(/[/()]/).map(normalizeSubjectText).filter(Boolean);
  const resolved = parts.map((part) => resolveSubjectKey(part, table)).filter(Boolean);
  if (resolved.length) {
    return {
      kind: resolved.length === 1 ? resolved[0].kind : 'combined',
      terms: [...new Set(resolved.flatMap((row) => row.terms))],
      countries: [...new Set(resolved.flatMap((row) => row.countries))],
      exclusions: [...new Set(resolved.flatMap((row) => row.exclusions))],
    };
  }
  return { kind: 'fallback', terms: [...new Set([key, ...parts])], countries: [], exclusions: [] };
}

function resolveSubjectKey(key, table) {
  const region = table.regions[key];
  if (region) return { kind: 'region', terms: region.terms, countries: region.countries, exclusions: region.exclusions || [] };
  const code = table.labels[key];
  if (code && table.countries[code]) return { kind: 'country', terms: [], countries: [code], exclusions: [] };
  return null;
}

/** The strong terms a region's subject matches on, normalized like the text they are matched against. */
export function subjectTermsForRegion(region) {
  const table = loadJudgedSubjectTable();
  const subject = resolveJudgedSubject(region, table);
  return [...new Set([...subject.terms, ...subject.countries.flatMap((code) => table.countries[code].terms)])];
}

/**
 * Word-bounded, case-folded match on normalized text. Demonyms and the `US`
 * token match case-sensitively on the raw text ("Polish" is a country,
 * "polish" a verb). A country's exclusions are removed first, and an
 * ambiguous name ("Georgia", "Jordan", "Chad") counts only beside a co-term.
 *
 * `regionMembers: false` keeps a named region to its own terms plus the
 * member countries its label names ("Israel/Gaza", "Iran Theater"). A news
 * item about one member is evidence about the region; a market about one
 * member asks a narrower question than the region's forecast (#9010).
 */
export function subjectMatcherForRegion(region, { regionMembers = true } = {}) {
  const table = loadJudgedSubjectTable();
  const subject = resolveJudgedSubject(region, table);
  const label = ` ${normalizeSubjectText(region)} `;
  const codes = regionMembers || subject.kind !== 'region'
    ? subject.countries
    : subject.countries.filter((code) => table.countries[code].terms.some((term) => label.includes(` ${term} `)));
  const countries = codes.map((code) => table.countries[code]);
  return {
    /**
     * `contextText` is the item's other field: an ambiguous name in the title
     * counts when its co-term sits in the description, and the reverse.
     */
    matches(rawText, contextText = '') {
      const raw = String(rawText || '');
      const normalized = normalizeSubjectText(raw);
      if (!normalized) return false;
      const padded = removeWholePhrases(` ${normalized} `, subject.exclusions);
      if (subject.terms.some((term) => padded.includes(` ${term} `))) return true;
      const context = ` ${normalizeSubjectText(`${raw} ${contextText || ''}`)} `;
      return countries.some((country) => countryMatches(country, raw, ` ${normalized} `, context));
    },
  };
}

// Exclusions are whole words or phrases: "niger state" removes "Niger State",
// never the "niger" in "Niger statement".
function removeWholePhrases(padded, phrases = []) {
  let text = padded;
  for (const phrase of phrases) text = text.split(` ${phrase} `).join(' ');
  return text;
}

function countryMatches(country, raw, padded, context) {
  const text = removeWholePhrases(padded, country.exclusions);
  const contextText = removeWholePhrases(context, country.exclusions);
  let rawText = raw;
  for (const phrase of country.exclusions) {
    // Ends at a word end or a demonym suffix: "south sudan" removes "South
    // Sudanese", but "french open" never eats "French opens".
    rawText = rawText.replace(new RegExp(`(^|[^A-Za-z0-9])${phrase.split(' ').map(escapeRegExp).join('[\\s\\-.\']+')}(?:ese|ian|an|n|i)?(?=$|[^A-Za-z0-9])`, 'gi'), '$1 ');
  }
  const has = (term) => text.includes(` ${term} `);
  if (country.terms.some(has)) return true;
  if (country.demonyms.some((demonym) => hasCasedToken(rawText, demonym))) return true;
  if (country.codeToken && hasCodeToken(rawText, country.codeToken)) return true;
  return Boolean(country.weak?.some(has) && country.coTerms.some((term) => contextText.includes(` ${term} `)));
}

function hasCasedToken(rawText, token) {
  return new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(token)}(?=$|[^A-Za-z0-9])`).test(rawText);
}

// "US" as a country, not "US$5bn" and not an all-caps headline ("JOIN US").
function hasCodeToken(rawText, token) {
  const pattern = new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(token)}(?![A-Za-z0-9$])`, 'g');
  for (const match of rawText.matchAll(pattern)) {
    const start = match.index + match[1].length;
    const before = rawText.slice(0, start).match(/([A-Za-z]+)\W*$/)?.[1] || '';
    const after = rawText.slice(start + token.length).match(/^\W*([A-Za-z]+)/)?.[1] || '';
    const shouting = (word) => word.length >= 2 && word === word.toUpperCase();
    if (!shouting(before) && !shouting(after)) return true;
  }
  return false;
}

function escapeRegExp(value) {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}


export function normalizeSubjectText(value) {
  return String(value || '')
    .normalize('NFKD')
    .replace(/\p{Diacritic}/gu, '')
    .toLowerCase()
    .replace(/&/g, ' and ')
    .replace(/[^a-z0-9]+/g, ' ')
    .trim();
}