// The view's pure half: its state read the forgiving way (main stores state as written, so a key can // be missing or the wrong shape), which call it shows, find in a transcript, and what it publishes // and says in the model's map. No React and no bridge here, so it runs under the tests. export type Row = Record export interface CallsState { /** The ticker. Empty shows the calendar. */ symbol: string exchange: string | null /** The call's fiscal year and quarter, both or neither; neither is the latest call. */ year: number | null quarter: number | null /** The calendar's day, YYYY-MM-DD; none is today. */ date: string | null find: string } export interface Call { year: number quarter: number date: string | null published: boolean } export interface CalendarRow { exchange: string symbol: string company: string year: number quarter: number date: string | null ready: boolean } /** One find match: the paragraph's index and the match's place in its text. */ export interface Match { paragraph: number start: number end: number } export interface Segment { text: string /** The match's number across the whole transcript, or null for the text between matches. */ match: number | null } // State. export function readState(raw: unknown): CallsState { const state = record(raw) ?? {} const year = int(state['year']) const quarter = int(state['quarter']) const both = year !== null && quarter !== null && quarter >= 1 && quarter <= 4 const exchange = typeof state['exchange'] === 'string' ? state['exchange'].trim().toUpperCase() : '' const date = state['date'] return { symbol: typeof state['symbol'] === 'string' ? state['symbol'].trim().toUpperCase() : '', exchange: exchange || null, year: both ? year : null, quarter: both ? quarter : null, date: typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, find: typeof state['find'] === 'string' ? state['find'].trim() : '', } } /** The state as the view writes it back, whole: only what is set, so a cleared key is gone. */ export function stored(state: CallsState): Row { const out: Row = { symbol: state.symbol } if (state.exchange) out['exchange'] = state.exchange if (state.year !== null && state.quarter !== null) { out['year'] = state.year out['quarter'] = state.quarter } if (state.date) out['date'] = state.date if (state.find) out['find'] = state.find return out } /** The quarter asked for, else the newest call with a transcript out. */ export function chosenQuarter(calls: Call[], year: number | null, quarter: number | null): { year: number; quarter: number } | null { if (year !== null && quarter !== null) return { year, quarter } const latest = calls.find((call) => call.published) return latest ? { year: latest.year, quarter: latest.quarter } : null } // Rows from a run. /** * Whether a run's meta answers this ask. A run keeps its last answer while the next one is out, so * a new ticker would otherwise be read against the last company's calls, and a new day against the * last day's calendar. */ export function answers(meta: Row, ask: Row): boolean { return Object.entries(ask).every(([key, value]) => meta[key] === value) } export function readCalls(rows: Row[] | undefined): Call[] { const calls: Call[] = [] for (const row of rows ?? []) { const year = int(row['year']) const quarter = int(row['quarter']) if (year === null || quarter === null) continue calls.push({ year, quarter, date: text(row['date']), published: row['published'] !== false }) } return calls } /** One string per row, in order; a row without text stays as an empty paragraph so the Q&A index still points right. */ export function readParagraphs(rows: Row[] | undefined): string[] { return (rows ?? []).map((row) => (typeof row['text'] === 'string' ? row['text'] : '')) } export function readCalendarRows(rows: Row[] | undefined): CalendarRow[] { const out: CalendarRow[] = [] for (const row of rows ?? []) { const exchange = text(row['exchange']) const symbol = text(row['symbol']) const year = int(row['year']) const quarter = int(row['quarter']) if (!exchange || !symbol || year === null || quarter === null) continue out.push({ exchange, symbol, company: text(row['company']) ?? symbol, year, quarter, date: text(row['date']), ready: row['ready'] === true }) } return out } // Find. /** Every occurrence of the query, as typed text rather than a pattern, whatever the case. */ export function findMatches(paragraphs: string[], query: string): Match[] { const q = query.trim() if (!q) return [] const pattern = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi') const matches: Match[] = [] paragraphs.forEach((paragraph, index) => { for (const found of paragraph.matchAll(pattern)) matches.push({ paragraph: index, start: found.index, end: found.index + found[0].length }) }) return matches } /** A paragraph cut into the text between matches and the matches themselves, in order. */ export function segments(paragraph: string, ranges: { start: number; end: number; index: number }[]): Segment[] { const out: Segment[] = [] let at = 0 for (const range of ranges) { if (range.start > at) out.push({ text: paragraph.slice(at, range.start), match: null }) out.push({ text: paragraph.slice(range.start, range.end), match: range.index }) at = range.end } if (at < paragraph.length || out.length === 0) out.push({ text: paragraph.slice(at), match: null }) return out } /** The match with up to `width` characters either side, cut back to whole words. */ export function snippet(paragraph: string, start: number, end: number, width = 80): string { const from = Math.max(0, start - width) const to = Math.min(paragraph.length, end + width) let head = paragraph.slice(from, start) let tail = paragraph.slice(end, to) if (from > 0) head = head.includes(' ') ? head.slice(head.indexOf(' ') + 1) : '' if (to < paragraph.length) tail = tail.includes(' ') ? tail.slice(0, tail.lastIndexOf(' ')) : '' return `${from > 0 ? '…' : ''}${head}${paragraph.slice(start, end)}${tail}${to < paragraph.length ? '…' : ''}` } // Output, for the orchestrator to read. Main refuses a publish over 4096 bytes of JSON. const PUBLISH_MAX = 4096 const RECENT_CALLS = 8 const SNIPPETS = 5 const CALENDAR_EVENTS = 20 export interface CallOutput { mode: 'call' symbol: string exchange: string | null company: string | null year: number | null quarter: number | null date: string | null calls: Call[] paragraphs: number /** The paragraph number the questions start at, as the view numbers them. */ qaParagraph: number | null find: string matches: number snippets: { paragraph: number; text: string }[] error: string | null } export function callOutput(input: { state: CallsState exchange: string | null company: string | null calls: Call[] chosen: { year: number; quarter: number } | null date: string | null paragraphs: string[] qaStart: number | null matches: Match[] error: string | null }): CallOutput { const { paragraphs, matches } = input return { mode: 'call', symbol: input.state.symbol, exchange: input.exchange, company: cap(input.company, 120), year: input.chosen?.year ?? null, quarter: input.chosen?.quarter ?? null, date: input.date, calls: input.calls.slice(0, RECENT_CALLS), paragraphs: paragraphs.length, qaParagraph: input.qaStart === null ? null : input.qaStart + 1, find: cap(input.state.find, 100) ?? '', matches: matches.length, snippets: matches.slice(0, SNIPPETS).map((m) => ({ paragraph: m.paragraph + 1, text: snippet(paragraphs[m.paragraph] ?? '', m.start, m.end, 80) })), error: cap(input.error, 300), } } /** * The transcript as the panel's text, which a model reads by path where output only counts it: a * line naming the call, which a citation of it takes as its title, what kind of transcript it is, * then the paragraphs under the numbers the view shows, the questions marked where they start. An * empty paragraph keeps its number and is left out. */ export function transcriptText(input: { symbol: string company: string | null year: number quarter: number date: string | null paragraphs: string[] qaStart: number | null }): string { const title = [`${input.symbol} Q${input.quarter} ${input.year} earnings call`, input.company, input.date?.slice(0, 10)].filter(Boolean).join(' · ') const blocks = [ `${title}\nMachine-transcribed plain transcript: no speaker names, and the paragraph breaks are inferred. ¶ numbers are the paragraphs as the view numbers them.`, ] input.paragraphs.forEach((paragraph, i) => { if (i === input.qaStart) blocks.push('Questions and answers') if (paragraph.trim()) blocks.push(`¶${i + 1} ${paragraph.trim()}`) }) return blocks.join('\n\n') } export interface CalendarOutput { mode: 'calendar' date: string count: number events: CalendarRow[] error: string | null } export function calendarOutput(day: string, rows: CalendarRow[], error: string | null): CalendarOutput { const output: CalendarOutput = { mode: 'calendar', date: day, count: rows.length, events: rows.slice(0, CALENDAR_EVENTS).map((row) => ({ ...row, company: cap(row.company, 60) ?? row.symbol })), error: cap(error, 300), } while (output.events.length > 0 && byteLength(JSON.stringify(output)) > PUBLISH_MAX) output.events.pop() return output } /** The model's one line for this panel. An output still about another company says nothing about this one. */ export function summarize(state: unknown, output: unknown): string { const read = readState(state) const out = record(output) const error = text(out?.['error']) if (!read.symbol) { const date = text(out?.['date']) if (out?.['mode'] !== 'calendar' || !date || (read.date && read.date !== date)) return read.date ? `Earnings calendar ${read.date}` : 'Earnings calendar, today' return error ? `Earnings calendar ${date}: ${error}` : `Earnings calendar ${date}: ${int(out['count']) ?? 0} calls` } if (out?.['mode'] !== 'call' || out['symbol'] !== read.symbol) return `${read.symbol} earnings call` if (error) return `${read.symbol} earnings call: ${error}` const year = int(out['year']) const quarter = int(out['quarter']) if (year === null || quarter === null) return `${read.symbol} earnings call` const date = text(out['date']) const paragraphs = int(out['paragraphs']) const find = text(out['find']) const matches = int(out['matches']) ?? 0 return [ `${read.symbol} Q${quarter} ${year} earnings call`, date ? `, ${date.slice(0, 10)}` : '', paragraphs !== null ? `, ${paragraphs} paragraphs` : '', find ? `; "${find}" ${matches} match${matches === 1 ? '' : 'es'}` : '', ].join('') } // Days. /** Today as YYYY-MM-DD in the viewer's own zone. */ export function today(now: Date): string { const pad = (n: number): string => String(n).padStart(2, '0') return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` } export function shiftDay(day: string, by: number): string { const [year, month, date] = day.split('-').map(Number) return new Date(Date.UTC(year!, month! - 1, date! + by)).toISOString().slice(0, 10) } // Small readers. function record(value: unknown): Row | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Row) : null } function text(value: unknown): string | null { return typeof value === 'string' && value !== '' ? value : null } function int(value: unknown): number | null { return typeof value === 'number' && Number.isInteger(value) ? value : null } function cap(value: string | null, max: number): string | null { if (value === null) return null return value.length > max ? `${value.slice(0, max - 1)}…` : value } function byteLength(value: string): number { return new TextEncoder().encode(value).length }