Jaspers Hub

jaspers / screener-mcp

pluginOfficialReviewed★ 0↓ 33

Screener MCP: Screens US SEC filers by fundamentals, holders and insider data, then checks criteria in words against filings via the Jaspers screener server.

32 KB
1// The screener's pure half: what the panel's state means, what goes to the server and what its
2// answers hold, how a value reads and how a typed one is read back, and the text a model is given.
3// The server (screener-mcp) owns the fields: their names, kinds, units and ops come from its guide,
4// so there is no list of them here. No React and no SDK: it is imported by the view and read by the
5// tests, which run under Node on their own.
6
7export type Kind = 'number' | 'text' | 'date' | 'list'
8/** How a number field reads: raw USD, a fraction shown as a percentage, a year, or a plain number. */
9export type Unit = 'usd' | 'fraction' | 'year' | 'number'
10
11export const OPS = ['gt', 'gte', 'lt', 'lte', 'eq', 'ne', 'between', 'in', 'contains', 'is_null', 'not_null'] as const
12export type Op = (typeof OPS)[number]
13
14export const VERDICTS = ['pass', 'fail', 'unclear', 'unverified'] as const
15export type Verdict = (typeof VERDICTS)[number]
16
17/** One field a filter, a sort or a column may name, as the server's guide describes it. */
18export interface Field {
19 field: string
20 group: string
21 kind: Kind
22 unit: Unit | null
23 ops: Op[]
24 sortable: boolean
25 description: string
26}
27
28/** The server's filter language: raw units, joined by AND. */
29export interface Filter {
30 field: string
31 op: Op
32 value?: number | string | (number | string)[]
33}
34
35export interface Sort {
36 field: string
37 dir: 'asc' | 'desc'
38}
39
40export interface Criterion {
41 id: string
42 question: string
43}
44
45/** The qualitative run of this panel. Present with no id, it is a request to start one. */
46export interface Run {
47 id?: string
48 status?: 'running' | 'done' | 'error'
49 error?: string
50}
51
52/** What the panel holds, and so what the orchestrator sets. */
53export interface State {
54 filters: Filter[]
55 /** The user's own list; empty for every company. */
56 tickers: string[]
57 includeUnlisted: boolean
58 sort: Sort
59 page: number
60 columns: string[]
61 /** The server's session, written by the view from its first answer. */
62 sessionId: string | null
63 criteria: Criterion[]
64 run: Run | null
65 /** Which verdicts the table shows once a run has results; empty for all. */
66 verdicts: Verdict[]
67 /** The ticker whose evidence is open; empty for none. */
68 open: string
69}
70
71export const DEFAULT_SORT: Sort = { field: 'revenue', dir: 'desc' }
72
73/** The columns a new screener shows. The fields filtered and sorted on are shown whatever this says. */
74export const DEFAULT_COLUMNS = [
75 'revenue',
76 'revenue_growth_yoy',
77 'gross_margin',
78 'operating_margin',
79 'net_margin',
80 'net_income',
81 'free_cash_flow',
82 'roe',
83 'debt_to_equity',
84 'insider_net_buy_value_6m',
85 'inst_holders',
86 'fiscal_year',
87]
88
89/** What the server allows in one run, for the editor to stop at. */
90export const MAX_CRITERIA = 8
91/** The most columns the panel's state takes. */
92export const MAX_COLUMNS = 40
93
94// The guide's field rows.
95
96const KINDS: Kind[] = ['number', 'text', 'date', 'list']
97const UNITS: Unit[] = ['usd', 'fraction', 'year', 'number']
98
99/** The rows of get_guide("fields"), read leniently: a row that names no field is dropped, a number field with no unit is a plain number. */
100export function readFields(rows: Record<string, unknown>[] | undefined): Field[] {
101 const fields: Field[] = []
102 for (const row of rows ?? []) {
103 if (typeof row.field !== 'string' || !row.field) continue
104 const kind = KINDS.includes(row.kind as Kind) ? (row.kind as Kind) : 'number'
105 const unit = UNITS.includes(row.unit as Unit) ? (row.unit as Unit) : kind === 'number' ? 'number' : null
106 fields.push({
107 field: row.field,
108 group: typeof row.group === 'string' ? row.group : '',
109 kind,
110 unit,
111 ops: Array.isArray(row.ops) ? row.ops.filter((op): op is Op => OPS.includes(op as Op)) : [],
112 sortable: typeof row.sortable === 'boolean' ? row.sortable : kind !== 'list',
113 description: typeof row.description === 'string' ? row.description : '',
114 })
115 }
116 return fields
117}
118
119/** The fields by group, in the guide's order. */
120export function groupFields(fields: Field[]): [string, Field[]][] {
121 const groups = new Map<string, Field[]>()
122 for (const field of fields) groups.set(field.group, [...(groups.get(field.group) ?? []), field])
123 return [...groups.entries()]
124}
125
126/** Parts of a field's name that do not read as words. */
127const WORDS: Record<string, string> = {
128 yoy: 'YoY',
129 cagr: 'CAGR',
130 roe: 'ROE',
131 roa: 'ROA',
132 eps: 'EPS',
133 fcf: 'FCF',
134 ocf: 'OCF',
135 sga: 'SG&A',
136 rnd: 'R&D',
137 ebitda: 'EBITDA',
138 sic: 'SIC',
139 cik: 'CIK',
140 hq: 'HQ',
141 lt: 'LT',
142 st: 'ST',
143 wa: 'avg',
144 inst: 'Inst.',
145 pct: '%',
146 '3y': '3Y',
147}
148
149/** A field's name as a column header and a chip say it: revenue_growth_yoy is "Revenue growth YoY". */
150export function fieldLabel(field: string): string {
151 const words = field.split('_').map((word) => WORDS[word] ?? word)
152 const first = words[0] ?? ''
153 // A word that was replaced is already written the way it reads; any other opens with a capital.
154 if (first === field.split('_')[0]) words[0] = first.charAt(0).toUpperCase() + first.slice(1)
155 return words.join(' ')
156}
157
158/** What every row carries whatever is asked for. Naming one as a column says nothing, so none is drawn twice and none is sent. */
159const IDENTITY = new Set(['cik', 'name', 'ticker', 'tickers', 'sic_description'])
160
161/** The columns the table draws after the company: the fields filtered on, the sort, then the ones asked for — the server's own rule. */
162export function shownColumns(filters: Filter[], sort: Sort, columns: string[]): string[] {
163 return [...new Set([...filters.map((f) => f.field), sort.field, ...columns])].filter((field) => !IDENTITY.has(field))
164}
165
166// Formatting. A cell says a number the way a screener does (B, M, K); a chip and the dialog say a
167// threshold in the unit the user types it in, so what is typed and what is shown agree.
168
169/** A number with K, M, B or T behind it when that says it exactly, so what is shown reads back as the same value. */
170function scaled(value: number, thousands: boolean): string {
171 const a = Math.abs(value)
172 const sign = value < 0 ? '-' : ''
173 for (const [suffix, scale] of SCALES) {
174 if (a < scale || (suffix === 'K' && !thousands)) continue
175 const short = String(clean(a / scale))
176 if (short.length <= 7) return `${sign}${short}${suffix}`
177 break
178 }
179 return `${sign}${clean(a)}`
180}
181
182const SCALES: [string, number][] = [
183 ['T', 1e12],
184 ['B', 1e9],
185 ['M', 1e6],
186 ['K', 1e3],
187]
188
189function money(value: number): string {
190 const a = Math.abs(value)
191 const sign = value < 0 ? '-' : ''
192 if (a >= 1e12) return `${sign}$${(a / 1e12).toFixed(2)}T`
193 if (a >= 1e9) return `${sign}$${(a / 1e9).toFixed(2)}B`
194 if (a >= 1e6) return `${sign}$${(a / 1e6).toFixed(1)}M`
195 if (a >= 1e3) return `${sign}$${(a / 1e3).toFixed(0)}K`
196 return `${sign}$${a.toFixed(2)}`
197}
198
199function plain(value: number): string {
200 const a = Math.abs(value)
201 if (a >= 1e9) return `${(value / 1e9).toFixed(2)}B`
202 if (a >= 1e6) return `${(value / 1e6).toFixed(1)}M`
203 if (Number.isInteger(value)) return value.toLocaleString('en-US')
204 return value.toFixed(2)
205}
206
207/** What one cell says. A value the company never reported is a dash, not a zero. */
208export function cellText(value: unknown, field?: Pick<Field, 'kind' | 'unit'>): string {
209 if (value === null || value === undefined) return '—'
210 if (Array.isArray(value)) return value.length > 0 ? value.join(' ') : '—'
211 if (typeof value === 'number') {
212 if (!Number.isFinite(value)) return '—'
213 if (field?.unit === 'usd') return money(value)
214 if (field?.unit === 'fraction') return `${(value * 100).toFixed(1)}%`
215 if (field?.unit === 'year') return String(value)
216 return plain(value)
217 }
218 return String(value)
219}
220
221/** Growth and returns read faster with a sign on them; nothing else is coloured. */
222export function signOf(field: string, value: unknown): 'up' | 'down' | null {
223 if (!/growth|cagr|change|^ro[ae]$/.test(field)) return null
224 if (typeof value !== 'number' || !Number.isFinite(value) || value === 0) return null
225 return value > 0 ? 'up' : 'down'
226}
227
228/** What the dialog writes beside a value, so the user knows what the number they type means. */
229export function unitHint(field: Pick<Field, 'kind' | 'unit'>): string {
230 if (field.kind === 'date') return 'YYYY-MM-DD'
231 if (field.kind !== 'number') return ''
232 if (field.unit === 'usd') return '$, with K M B'
233 if (field.unit === 'fraction') return '%'
234 if (field.unit === 'year') return 'year'
235 return 'K M B allowed'
236}
237
238const SCALE: Record<string, number> = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }
239
240/**
241 * A value as the user types it, in the server's raw units, or null when it cannot be read: "500M" and
242 * "$2B" are dollars, "20" on a fraction is 0.2, a date is ISO. Text is taken as it is.
243 */
244export function parseValue(text: string, field: Pick<Field, 'kind' | 'unit'>): number | string | null {
245 const typed = text.trim()
246 if (!typed) return null
247 if (field.kind === 'date') return /^\d{4}-\d{2}-\d{2}$/.test(typed) ? typed : null
248 if (field.kind !== 'number') return typed
249 if (field.unit === 'year') return /^\d{4}$/.test(typed) ? Number(typed) : null
250 const match = /^(-)?\s*\$?\s*(-)?\s*(\d[\d,]*\.?\d*|\.\d+)\s*([kmbt])?\s*(%)?$/i.exec(typed)
251 if (!match) return null
252 const number = Number(match[3]!.replace(/,/g, ''))
253 if (!Number.isFinite(number)) return null
254 const fraction = field.unit === 'fraction'
255 // A percent sign belongs on a fraction and a scale does not; anywhere else it is the other way round.
256 if (fraction ? match[4] : match[5]) return null
257 const signed = match[1] || match[2] ? -number : number
258 return clean(fraction ? signed / 100 : signed * (SCALE[match[4]?.toLowerCase() ?? ''] ?? 1))
259}
260
261/** A raw value as the user would type it, so a filter reads in the dialog the way it was entered. */
262export function valueText(raw: number | string, field: Pick<Field, 'kind' | 'unit'> | undefined): string {
263 if (typeof raw !== 'number') return raw
264 if (field?.unit === 'fraction') return `${clean(raw * 100)}%`
265 if (field?.unit === 'year') return String(raw)
266 if (field?.unit === 'usd') return raw < 0 ? `-$${scaled(-raw, true)}` : `$${scaled(raw, true)}`
267 return scaled(raw, false)
268}
269
270const OP_TEXT: Record<Op, string> = {
271 gt: '>',
272 gte: '≥',
273 lt: '<',
274 lte: '≤',
275 eq: '=',
276 ne: '≠',
277 between: 'between',
278 in: 'in',
279 contains: 'contains',
280 is_null: 'is empty',
281 not_null: 'has a value',
282}
283
284/** An op as the dialog's list and a chip say it. */
285export function opText(op: Op): string {
286 return OP_TEXT[op]
287}
288
289/** One filter in words: "Revenue ≥ $500M", "Gross margin 20% to 40%", "HQ state in TX, FL". */
290export function filterText(filter: Filter, field: Pick<Field, 'kind' | 'unit'> | undefined): string {
291 const label = fieldLabel(filter.field)
292 const say = (value: number | string): string =>
293 typeof value === 'string' && field?.kind !== 'date' ? `"${value}"` : valueText(value, field)
294 const { op, value } = filter
295 if (op === 'is_null' || op === 'not_null') return `${label} ${OP_TEXT[op]}`
296 if (op === 'between' && Array.isArray(value) && value.length === 2) return `${label} ${say(value[0]!)} to ${say(value[1]!)}`
297 if (Array.isArray(value)) return `${label} ${OP_TEXT[op]} ${value.map((v) => valueText(v, field)).join(', ')}`
298 return `${label} ${OP_TEXT[op]} ${value === undefined ? '?' : say(value)}`
299}
300
301/** A filter as the dialog edits it: what was typed, not yet read. */
302export interface Draft {
303 field: string
304 op: Op
305 a: string
306 b: string
307}
308
309export function toDraft(filter: Filter, field: Pick<Field, 'kind' | 'unit'> | undefined): Draft {
310 const { value } = filter
311 const text = (v: number | string | undefined): string => (v === undefined ? '' : valueText(v, field))
312 if (filter.op === 'between' && Array.isArray(value)) return { field: filter.field, op: filter.op, a: text(value[0]), b: text(value[1]) }
313 if (Array.isArray(value)) return { field: filter.field, op: filter.op, a: value.map(text).join(', '), b: '' }
314 return { field: filter.field, op: filter.op, a: text(value), b: '' }
315}
316
317/** The filter a draft says, or a sentence about what is wrong with it. */
318export function fromDraft(draft: Draft, field: Field | undefined): { filter: Filter } | { error: string } {
319 if (!field) return { error: 'Pick a field.' }
320 if (!field.ops.includes(draft.op)) return { error: `${fieldLabel(field.field)} takes ${field.ops.map(opText).join(', ')}.` }
321 const base = { field: field.field, op: draft.op }
322 if (draft.op === 'is_null' || draft.op === 'not_null') return { filter: base }
323 const hint = unitHint(field)
324 const wrong = (text: string): { error: string } => ({ error: `Cannot read "${text}"${hint ? ` (${hint})` : ''}.` })
325 if (draft.op === 'between') {
326 const low = parseValue(draft.a, field)
327 const high = parseValue(draft.b, field)
328 if (low === null) return wrong(draft.a)
329 if (high === null) return wrong(draft.b)
330 return { filter: { ...base, value: [low, high] } }
331 }
332 // A list field is asked "any of", whatever the op's name: the server takes a list there.
333 if (draft.op === 'in') {
334 const parts = draft.a.split(',').map((part) => part.trim()).filter(Boolean)
335 if (parts.length === 0) return { error: 'Give at least one value, separated by commas.' }
336 const values: (number | string)[] = []
337 for (const part of parts) {
338 const value = parseValue(part, field)
339 if (value === null) return wrong(part)
340 values.push(value)
341 }
342 return { filter: { ...base, value: values } }
343 }
344 if (draft.op === 'contains') return draft.a.trim() ? { filter: { ...base, value: draft.a.trim() } } : { error: 'Give the text to look for.' }
345 const value = parseValue(draft.a, field)
346 return value === null ? wrong(draft.a) : { filter: { ...base, value } }
347}
348
349/** Tickers as typed, as the SEC writes them: upper case, once each, split on commas, spaces and lines. */
350export function readTickers(text: string): string[] {
351 return [...new Set(text.split(/[\s,;]+/).map((t) => t.trim().toUpperCase()).filter(Boolean))]
352}
353
354// The screen: what one call sends, and what its answer holds.
355
356/** What the server's session holds, as its last answer said it. */
357export interface Echo {
358 sessionId: string
359 filters: Filter[]
360 tickers: string[]
361 includeUnlisted: boolean
362}
363
364/** A screen_companies answer without its rows: the dataset's meta, read leniently. */
365export interface Screen {
366 sessionId: string | null
367 universe: number
368 count: number
369 countBefore: number | null
370 applied: (Filter & { matches: number })[]
371 tickers: string[]
372 unknownTickers: string[]
373 includeUnlisted: boolean
374 ready: boolean
375 max: number
376}
377
378export function readScreen(meta: Record<string, unknown>): Screen | null {
379 if (typeof meta.count !== 'number') return null
380 const applied = Array.isArray(meta.applied_filters) ? meta.applied_filters.filter(isRecord) : []
381 return {
382 sessionId: typeof meta.session_id === 'string' ? meta.session_id : null,
383 universe: number(meta.universe),
384 count: meta.count,
385 countBefore: typeof meta.count_before === 'number' ? meta.count_before : null,
386 applied: applied
387 .filter((f) => typeof f.field === 'string' && OPS.includes(f.op as Op))
388 .map((f) => ({
389 field: f.field as string,
390 op: f.op as Op,
391 ...(f.value === undefined || f.value === null ? {} : { value: f.value as Filter['value'] }),
392 matches: number(f.matches),
393 })),
394 tickers: strings(meta.tickers),
395 unknownTickers: strings(meta.unknown_tickers),
396 includeUnlisted: meta.include_unlisted === true,
397 ready: meta.qualitative_ready === true,
398 max: number(meta.qualitative_max),
399 }
400}
401
402export function echoOf(screen: Screen): Echo | null {
403 if (!screen.sessionId) return null
404 return {
405 sessionId: screen.sessionId,
406 filters: screen.applied.map(({ matches: _matches, ...filter }) => filter),
407 tickers: screen.tickers,
408 includeUnlisted: screen.includeUnlisted,
409 }
410}
411
412type Listed = Pick<State, 'filters' | 'tickers' | 'includeUnlisted'>
413
414/** Whether the server's session holds the list this state asks for. A run freezes the session's list, so it waits for this. */
415export function inSync(state: Listed & { sessionId: string | null }, echo: Echo | null): boolean {
416 return (
417 echo !== null &&
418 echo.sessionId === state.sessionId &&
419 same(echo.filters.map(parts), state.filters.map(parts)) &&
420 same(echo.tickers, readTickers(state.tickers.join(' '))) &&
421 echo.includeUnlisted === state.includeUnlisted
422 )
423}
424
425/**
426 * What one screen_companies call sends. The list (filters, include_unlisted) goes whole when the
427 * session does not hold it yet, and is left out when only the sort, the page or the columns moved:
428 * to the server a call that names the list is a change of it, and one that does not is a read.
429 * No tickers leave the view unless the user has a list of their own: they go as the server's
430 * tickers parameter, an empty one only to clear a list the session still holds, and never as a column.
431 */
432export function screenArgs(
433 state: Listed & Pick<State, 'sort' | 'page' | 'columns' | 'sessionId'>,
434 echo: Echo | null,
435 pageSize: number,
436): Record<string, unknown> {
437 const columns = state.columns.filter((field) => !IDENTITY.has(field))
438 const read = { sort: state.sort, columns, limit: pageSize, offset: state.page * pageSize }
439 if (inSync(state, echo)) return { session_id: state.sessionId, ...read }
440 // Left out, the session's list stays, and a new session has none: [] is only how a list is taken back.
441 const held = (echo?.tickers.length ?? 0) > 0
442 return {
443 ...(state.sessionId ? { session_id: state.sessionId } : {}),
444 filters: state.filters,
445 ...(state.tickers.length > 0 ? { tickers: state.tickers } : held ? { tickers: [] } : {}),
446 include_unlisted: state.includeUnlisted,
447 ...read,
448 }
449}
450
451/** How far over the qualitative stage's limit the list is, in the words the view and the orchestrator are told. */
452export function overLimit(screen: Pick<Screen, 'count' | 'max'>): string {
453 if (screen.count === 0) return 'The list is empty: there is nothing to check.'
454 const over = screen.count - screen.max
455 const n = (value: number): string => value.toLocaleString('en-US')
456 return `The list has ${n(screen.count)} companies, ${n(over)} over the ${screen.max} the qualitative stage takes. Narrow it first.`
457}
458
459// The qualitative stage.
460
461/** A criterion's id from its question: the words that say something, as the server's pattern wants them (^[a-z0-9_]{1,40}$), unlike any taken. */
462export function slug(question: string, taken: string[] = []): string {
463 const words = question.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(' ').filter(Boolean)
464 // A letter on its own is what an apostrophe left behind.
465 const telling = words.filter((word) => !SMALL_WORDS.has(word) && (word.length > 1 || /\d/.test(word)))
466 // Whole words, up to five, while they fit with room left for a number behind them.
467 let base = ''
468 for (const word of (telling.length > 0 ? telling : words).slice(0, 5)) {
469 if (base && base.length + 1 + word.length > 36) break
470 base = base ? `${base}_${word}` : word.slice(0, 36)
471 }
472 base ||= 'criterion'
473 let id = base
474 for (let n = 2; taken.includes(id); n++) id = `${base}_${n}`
475 return id
476}
477
478const SMALL_WORDS = new Set(
479 'a an and any are as at be been by do does for from has have in is it its of on or that the their this to was were which with company'.split(' '),
480)
481
482/**
483 * Whether a run still has to be started. A `run` with no id is the request, from the Run button or
484 * from the orchestrator; what a model fills in beside it (a status, an empty id) does not count as
485 * started. Only what the view itself leaves does: the server's run id, or a failed start with its reason.
486 */
487export function needsStart(criteria: Criterion[] | undefined, run: Run | null | undefined): boolean {
488 return Boolean(criteria?.length) && run !== null && run !== undefined && !run.id && !(run.status === 'error' && run.error)
489}
490
491/**
492 * Where the qualitative stage stands, in one word, for the bar and the orchestrator alike. A run asked
493 * for with no criteria never starts, and must not read as if it had: that is how a reply came to promise
494 * results of a run that did not exist. What a model fills in beside the request counts no more here than in needsStart.
495 */
496export type Stage = 'none' | 'no_criteria' | 'starting' | 'running' | 'done' | 'error'
497
498export function stageOf(criteria: Criterion[] | undefined, run: Run | null | undefined): Stage {
499 if (!run) return 'none'
500 if (run.status === 'error' && run.error) return 'error'
501 if (!run.id) return criteria?.length ? 'starting' : 'no_criteria'
502 return run.status === 'done' ? 'done' : 'running'
503}
504
505/** What a started run answers with. */
506export function readStart(text: string): { runId: string; total: number } {
507 const value = parseJson(text)
508 if (typeof value.run_id !== 'string' || !value.run_id) throw new Error('the run did not start: no run id came back')
509 return { runId: value.run_id, total: number(value.total) }
510}
511
512export interface Progress {
513 total: number
514 queued: number
515 running: number
516 error: number
517 pass: number
518 fail: number
519 unclear: number
520 unverified: number
521}
522
523/** One quote of a filing that an answer rests on, already checked by the server. */
524export interface Citation {
525 id: string
526 title: string
527 url: string | null
528 quote: string
529}
530
531export interface CriterionResult {
532 id: string
533 verdict: string
534 rationale: string
535 verification: string
536 confidence: number | null
537 note: string
538 /** Citation ids. */
539 evidence: string[]
540 gathered: { searched: number; judged: number; paragraphs: number; relevant: number } | null
541}
542
543export interface CompanyResult {
544 cik: number
545 ticker: string | null
546 name: string | null
547 status: string
548 verdict: Verdict | null
549 error: string | null
550 criteria: CriterionResult[]
551}
552
553export interface RunSnapshot {
554 status: 'queued' | 'running' | 'done'
555 progress: Progress
556 criteria: Criterion[]
557 results: CompanyResult[]
558 citations: Record<string, Citation>
559}
560
561/**
562 * One get_qualitative_screen answer: its results are the dataset's rows, the rest its meta. Read
563 * leniently. A criterion's evidence is a list of citation ids; the citations themselves sit once at the top.
564 */
565export function readRun(meta: Record<string, unknown>, rows: Record<string, unknown>[]): RunSnapshot {
566 const citations: Record<string, Citation> = {}
567 for (const entry of Array.isArray(meta.citations) ? meta.citations : []) {
568 const citation = readCitation(entry)
569 if (citation) citations[citation.id] = citation
570 }
571 const results = rows.map((row): CompanyResult => ({
572 cik: number(row.cik),
573 ticker: typeof row.ticker === 'string' ? row.ticker : null,
574 name: typeof row.name === 'string' ? row.name : null,
575 status: typeof row.status === 'string' ? row.status : 'queued',
576 verdict: VERDICTS.includes(row.verdict as Verdict) ? (row.verdict as Verdict) : null,
577 error: typeof row.error === 'string' ? row.error : null,
578 criteria: (Array.isArray(row.criteria) ? row.criteria.filter(isRecord) : []).map((c) => readCriterion(c, citations)),
579 }))
580 const progress = isRecord(meta.progress) ? meta.progress : {}
581 return {
582 status: meta.status === 'done' ? 'done' : meta.status === 'queued' ? 'queued' : 'running',
583 progress: {
584 total: number(progress.total),
585 queued: number(progress.queued),
586 running: number(progress.running),
587 error: number(progress.error),
588 pass: number(progress.pass),
589 fail: number(progress.fail),
590 unclear: number(progress.unclear),
591 unverified: number(progress.unverified),
592 },
593 criteria: (Array.isArray(meta.criteria) ? meta.criteria.filter(isRecord) : [])
594 .filter((c) => typeof c.id === 'string' && typeof c.question === 'string')
595 .map((c) => ({ id: c.id as string, question: c.question as string })),
596 results,
597 citations,
598 }
599}
600
601/** The same answer when it came back as text rather than rows. */
602export function readRunText(text: string): RunSnapshot {
603 const { results, ...meta } = parseJson(text)
604 return readRun(meta, Array.isArray(results) ? results.filter(isRecord) : [])
605}
606
607function readCitation(entry: unknown): Citation | null {
608 if (!isRecord(entry) || typeof entry.id !== 'string' || !entry.id) return null
609 const url = typeof entry.url === 'string' && entry.url.startsWith('https://') ? entry.url : null
610 return {
611 id: entry.id,
612 title: typeof entry.title === 'string' ? entry.title : '',
613 url,
614 quote: typeof entry.quote === 'string' ? entry.quote : '',
615 }
616}
617
618function readCriterion(c: Record<string, unknown>, citations: Record<string, Citation>): CriterionResult {
619 // An id with no citation behind it has nothing to show, so it is not kept.
620 const evidence = (Array.isArray(c.evidence) ? c.evidence : []).filter((id): id is string => typeof id === 'string' && id in citations)
621 const gathered = isRecord(c.gathered) ? c.gathered : null
622 return {
623 id: typeof c.id === 'string' ? c.id : '',
624 verdict: typeof c.verdict === 'string' ? c.verdict : '',
625 rationale: typeof c.rationale === 'string' ? c.rationale : '',
626 verification: typeof c.verification === 'string' ? c.verification : '',
627 confidence: typeof c.confidence === 'number' ? c.confidence : null,
628 note: typeof c.note === 'string' ? c.note : '',
629 evidence,
630 gathered: gathered && {
631 searched: Array.isArray(gathered.searched) ? gathered.searched.length : 0,
632 judged: number(gathered.judged),
633 paragraphs: number(gathered.paragraphs),
634 relevant: Array.isArray(gathered.good) ? gathered.good.length : 0,
635 },
636 }
637}
638
639/** The companies that passed, by ticker. The table's rows are the list that was read; only these are what the user asked for. */
640export function passedTickers(run: Pick<RunSnapshot, 'results'> | null): string[] {
641 return (run?.results ?? []).flatMap((result) => (result.verdict === 'pass' && result.ticker ? [result.ticker] : []))
642}
643
644/** Where a run is, in one line: how many companies are read, then the counts by verdict. */
645export function progressText(progress: Progress, status: RunSnapshot['status']): string {
646 const read = progress.total - progress.queued - progress.running
647 const head = status === 'done' ? `${progress.total} read` : `reading: ${read} of ${progress.total}`
648 const counts = VERDICTS.map((verdict) => `${progress[verdict]} ${verdict}`)
649 if (progress.error > 0) counts.push(`${progress.error} error`)
650 return `${head} · ${counts.join(' · ')}`
651}
652
653/** A verification that did not hold, in words; nothing for one that did, or for a claim of absence, which cites nothing. */
654export function verificationMark(verification: string): string | null {
655 if (!verification || verification === 'supports' || verification === 'not_checked') return null
656 if (verification === 'contradicts') return 'The cited evidence contradicts this verdict.'
657 if (verification === 'says_nothing') return 'The cited evidence does not show this.'
658 if (verification === 'uncertain') return 'The verifier was not confident the cited evidence carries this verdict.'
659 return `Verification: ${verification}.`
660}
661
662/** What was searched and read for one criterion, in one line. */
663export function gatheredText(gathered: NonNullable<CriterionResult['gathered']>): string {
664 const n = (count: number, one: string, many: string): string => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`
665 return [
666 `${n(gathered.searched, 'search', 'searches')} made`,
667 `${n(gathered.judged, 'passage', 'passages')} judged`,
668 `${n(gathered.paragraphs, 'paragraph', 'paragraphs')} read`,
669 `${gathered.relevant.toLocaleString('en-US')} relevant`,
670 ].join(', ')
671}
672
673/**
674 * The open company's evidence as text a model can quote. The first line names what it is, since a
675 * citation of the panel takes that line as its title; each quote is followed by its marker, [^id],
676 * which the chat resolves against the citations of the same tool result.
677 */
678export function evidenceText(company: CompanyResult, questions: Criterion[], citations: Record<string, Citation>, runId: string): string {
679 const name = company.name ? ` (${company.name})` : ''
680 const lines = [`Screener evidence for ${company.ticker ?? `CIK ${company.cik}`}${name}, qualitative run ${runId}: ${company.verdict ?? company.status}`]
681 if (company.error) lines.push('', `Error: ${company.error}`)
682 for (const c of company.criteria) {
683 const question = questions.find((q) => q.id === c.id)?.question
684 const confidence = c.confidence === null ? '' : `, confidence ${c.confidence.toFixed(2)}`
685 lines.push('', `## ${c.id}${question ? `: ${question}` : ''}`, `Verdict: ${c.verdict} (verification: ${c.verification || 'none'}${confidence})`)
686 if (c.rationale) lines.push(`Rationale: ${c.rationale}`)
687 if (c.note) lines.push(`Note: ${c.note}`)
688 for (const id of c.evidence) {
689 const citation = citations[id]
690 if (citation) lines.push(`- "${citation.quote}" [^${citation.id}]${citation.title ? ` (${citation.title})` : ''}`)
691 }
692 if (c.gathered) lines.push(`Retrieval: ${gatheredText(c.gathered)}.`)
693 }
694 return lines.join('\n')
695}
696
697// What the orchestrator reads.
698
699/** What the view publishes: the screen behind the rows on screen, and where the run is. */
700export interface Output {
701 session_id: string | null
702 universe: number
703 count: number
704 applied_filters: (Filter & { matches: number })[]
705 /** The tickers on this page, in order. */
706 tickers: string[]
707 unknown_tickers: string[]
708 sort: Sort
709 qualitative_ready: boolean
710 qualitative_max: number
711 /** status is a Stage. passed: the tickers with a pass, once there are results; the rows above are only the list that was read. */
712 qualitative?: { run_id: string | null; status: string; progress: Progress | null; passed?: string[]; error?: string }
713 open: string
714}
715
716/** The app takes 4,096 bytes of output: the tickers at the end of the page give way first. */
717export const OUTPUT_BYTES = 3800
718
719export function fitOutput(output: Output): Output {
720 let fitted = output
721 while (fitted.tickers.length > 0 && byteLength(JSON.stringify(fitted)) > OUTPUT_BYTES) {
722 fitted = { ...fitted, tickers: fitted.tickers.slice(0, Math.floor(fitted.tickers.length * 0.8)) }
723 }
724 return fitted
725}
726
727/** The one line the model's map carries: how many pass, of how many, behind which screen, and where the run is. */
728export function summarize(state: Partial<State>, output: Partial<Output>): string {
729 const n = (value: number | undefined): string => (value ?? 0).toLocaleString('en-US')
730 const filters = state.filters ?? []
731 const parts = [`Screener: ${n(output.count)} of ${n(output.universe)} pass`]
732 if (filters.length > 0) parts[0] += ` ${filters.length} filter${filters.length === 1 ? '' : 's'}`
733 if (state.tickers?.length) parts.push(`own list of ${state.tickers.length}`)
734 const sort = state.sort ?? DEFAULT_SORT
735 parts.push(`sort ${sort.field} ${sort.dir}`)
736 const q = output.qualitative
737 if (q) {
738 const progress = q.progress
739 parts.push(
740 q.status === 'error'
741 ? `qualitative run failed: ${q.error ?? 'unknown error'}`
742 : progress
743 ? `qualitative run ${q.status}: ${progressText(progress, q.status === 'done' ? 'done' : 'running')}`
744 : q.status === 'no_criteria'
745 ? 'a qualitative run was asked for but NOT started: state.criteria is empty'
746 : `qualitative run ${q.status}`,
747 )
748 } else if (output.qualitative_ready) parts.push('ready for the qualitative stage')
749 if (output.open) parts.push(`evidence open for ${output.open}`)
750 return parts.join(', ')
751}
752
753// Small things.
754
755function parseJson(text: string): Record<string, unknown> {
756 try {
757 const value: unknown = JSON.parse(text)
758 return isRecord(value) ? value : {}
759 } catch {
760 return {}
761 }
762}
763
764function strings(value: unknown): string[] {
765 return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
766}
767
768function number(value: unknown): number {
769 return typeof value === 'number' && Number.isFinite(value) ? value : 0
770}
771
772function isRecord(value: unknown): value is Record<string, unknown> {
773 return typeof value === 'object' && value !== null && !Array.isArray(value)
774}
775
776/** A filter by what it says, whatever order its keys were written in. */
777function parts(filter: Filter): unknown[] {
778 return [filter.field, filter.op, filter.value ?? null]
779}
780
781function same(a: unknown, b: unknown): boolean {
782 return JSON.stringify(a) === JSON.stringify(b)
783}
784
785/** A product or a quotient without the digits floating point adds at the end: 15.3 / 100 is 0.153. */
786function clean(value: number): number {
787 return Number(value.toPrecision(12))
788}
789
790function byteLength(text: string): number {
791 return new TextEncoder().encode(text).length
792}