moment-fence.ts5.0 KBView on GitHub
/**
 * Parsing for the ```moment fence — a single coaching moment from a call.
 *
 * Body shape: `key=[redacted] header lines, a blank line, then up to three `## context` /
 * `## moment` / `## reaction` sections whose lines are `**Speaker** (mm:ss) text`. Everything
 * inside is readable markdown, so the moment still reads correctly with no node view.
 *
 * See apps/server/docs/coaching-agent.md §3.2 part E.
 */

export type MomentVerdict = 'good' | 'bad' | 'questionable';

export interface MomentTurn {
  speaker?: string;
  at?: string;
  text: string;
}

export interface ParsedMoment {
  /** Deal / conversation name — the first crumb, clickable when `conversation` is present. */
  deal: string;
  /** Cedar conversation id, so the crumb can navigate to the deal. */
  conversationId?: string;
  /** The meeting / event name — the second crumb. */
  meeting: string;
  /** The call's date — the third crumb. */
  date?: string;
  at?: string;
  /** Criterion id, e.g. "D2" — rendered on its own row beside the readable name. */
  criterion?: string;
  /** Full criterion name, e.g. "Genuinely curious". */
  criterionName?: string;
  /** Fathom (or other provider) share link for the recording. */
  recording?: string;
  /**
   * `crm_meeting_events.external_id` — what the recording player resolves against.
   *
   * The card plays the call from `externalId`, not from `recording`. A stored URL is either
   * already dead (Circleback signs for 24h) or not media at all (Fathom stores a web page),
   * so a document that embedded one would stop working the day after it was written. The id
   * is stable forever and the server mints from it at view time.
   */
  externalId?: string;
  /** Canonical provider id, lowercased. Display only — the server resolves the real one. */
  provider?: string;
  verdict: MomentVerdict;
  coaching: string;
  context: MomentTurn[];
  moment: MomentTurn[];
  reaction: MomentTurn[];
}

const VERDICTS: readonly MomentVerdict[] = ['good', 'bad', 'questionable'];

/** `**Keenan** (12:40) When you say a mess…` → a turn. A bare line is text with no speaker. */
function parseTurn(line: string): MomentTurn | null {
  const trimmed = line.trim();
  if (!trimmed) return null;
  const match = /^\*\*(.+?)\*\*\s*(?:\(([^)]+)\))?\s*(.*)$/.exec(trimmed);
  if (!match) return { text: trimmed };
  const [, speaker, at, text] = match;
  return { speaker: speaker.trim(), ...(at ? { at: at.trim() } : {}), text: text.trim() };
}

/**
 * Parse a fence body into a moment. Returns null when a required header key is missing or the
 * `## moment` section is empty — the node then renders the body raw rather than a broken card.
 */
export function parseMoment(body: string): ParsedMoment | null {
  const lines = body.split('\n');

  const header: Record<string, string> = {};
  let i = 0;
  for (; i < lines.length; i += 1) {
    const line = lines[i];
    if (!line.trim()) break;
    if (line.trimStart().startsWith('##')) break;
    const idx = line.indexOf(':');
    if (idx <= 0) break;
    header[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
  }

  const sections: Record<string, MomentTurn[]> = { context: [], moment: [], reaction: [] };
  let current: string | null = null;
  for (; i < lines.length; i += 1) {
    const line = lines[i];
    const heading = /^##\s+(context|moment|reaction)\s*$/i.exec(line.trim());
    if (heading) {
      current = heading[1].toLowerCase();
      continue;
    }
    if (!current) continue;
    const turn = parseTurn(line);
    if (turn) sections[current].push(turn);
  }

  const verdictRaw = (header.verdict ?? '').toLowerCase() as MomentVerdict;
  const verdict = VERDICTS.includes(verdictRaw) ? verdictRaw : null;

  if (!header.deal || !header.meeting || !verdict || !header.coaching) return null;
  if (sections.moment.length === 0) return null;

  // `criterion: D2 Genuinely curious` splits into id + readable name, so the card can put the id
  // on its own row rather than leading with a code nobody remembers.
  const criterionRaw = header.criterion ?? '';
  // "D2 Genuinely curious" → id + name; a bare "D2" → id only; anything else → name only.
  const criterionMatch = /^([DM]\d+)(?:\s+(.*))?$/.exec(criterionRaw);

  return {
    deal: header.deal,
    ...(header.conversation ? { conversationId: header.conversation } : {}),
    meeting: header.meeting,
    ...(header.date ? { date: header.date } : {}),
    ...(header.at ? { at: header.at } : {}),
    ...(criterionMatch
      ? {
          criterion: criterionMatch[1],
          ...(criterionMatch[2] ? { criterionName: criterionMatch[2] } : {}),
        }
      : criterionRaw
        ? { criterionName: criterionRaw }
        : {}),
    ...(header.recording ? { recording: header.recording } : {}),
    ...(header.externalid ? { externalId: header.externalid } : {}),
    ...(header.provider ? { provider: header.provider.toLowerCase() } : {}),
    verdict,
    coaching: header.coaching,
    context: sections.context,
    moment: sections.moment,
    reaction: sections.reaction,
  };
}