Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.
| 1 | import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react' |
| 2 | import { useBridge, useData, usePanelState, usePublish, usePublishText, type Bridge, type PanelRef } from '@jaspers-ai/sdk' |
| 3 | import { ColumnsDialog } from './Columns' |
| 4 | import { EvidencePane, type Evidenced } from './Evidence' |
| 5 | import { FilterBar, FilterDialog, type Listed } from './Filters' |
| 6 | import { Funnel } from './Funnel' |
| 7 | import { QualitativeBar, RunProgress } from './Qualitative' |
| 8 | import { Table } from './Table' |
| 9 | import { |
| 10 | DEFAULT_COLUMNS, |
| 11 | DEFAULT_SORT, |
| 12 | echoOf, |
| 13 | evidenceText, |
| 14 | fitOutput, |
| 15 | followArgs, |
| 16 | inSync, |
| 17 | mergeRun, |
| 18 | needsStart, |
| 19 | overLimit, |
| 20 | passedTickers, |
| 21 | publishedFilters, |
| 22 | readFields, |
| 23 | readRun, |
| 24 | readRunText, |
| 25 | readScreen, |
| 26 | readStart, |
| 27 | screenArgs, |
| 28 | shownColumns, |
| 29 | stageOf, |
| 30 | type Criterion, |
| 31 | type Echo, |
| 32 | type Filter, |
| 33 | type Run, |
| 34 | type RunSnapshot, |
| 35 | type Sort, |
| 36 | type State, |
| 37 | type Verdict, |
| 38 | } from './screener' |
| 39 | import './styles.css' |
| 40 | |
| 41 | // The view. Everything it shows comes out of the panel's state: the filters, the list of tickers, |
| 42 | // the sort, the page and the columns go into the screen source's arguments, so changing any re-runs |
| 43 | // it, and the criteria and the run drive the qualitative stage. What is on screen is published |
| 44 | // back, which is how the orchestrator reads the table without being told what is in it. |
| 45 | |
| 46 | /** One server page, the most the server sends. The table scrolls it; Prev and Next ask for the next one. */ |
| 47 | const PAGE = 200 |
| 48 | /** |
| 49 | * How long one read of a run waits on the server while the run goes on, and so how often the run on |
| 50 | * screen is brought up to date. The server holds the call that long and answers with the run as it is |
| 51 | * then; it tells of each company as it finishes only in progress notices, which do not reach a view. |
| 52 | */ |
| 53 | const POLL_SECONDS = 3 |
| 54 | const FIELDS = { topic: 'fields' } |
| 55 | const NONE: never[] = [] |
| 56 | |
| 57 | /** One get_qualitative_screen answer, read: its results are a dataset's rows in main, the rest its meta. */ |
| 58 | async function readRunFrom(bridge: Bridge, args: Record<string, unknown>): Promise<RunSnapshot> { |
| 59 | const result = await bridge.runSource('screener-mcp/qualitative', args, { fresh: true }) |
| 60 | return result.kind === 'dataset' ? readRun(result.meta, await bridge.datasetRows(result.datasetId)) : readRunText(result.text) |
| 61 | } |
| 62 | |
| 63 | function sleep(ms: number): Promise<void> { |
| 64 | return new Promise((resolve) => setTimeout(resolve, ms)) |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * The panel's state arrives a round trip after the frame mounts, and the screen is nothing without |
| 69 | * it: a run with the default filters would ask for the whole universe and publish it as what the |
| 70 | * user is looking at, for the moment before the real filters land. So the table is a component of |
| 71 | * its own, mounted once the state is here and handed it as the initial value of every key, which |
| 72 | * makes its first source run the right one. |
| 73 | */ |
| 74 | export function ScreenerView({ panel }: { panel: PanelRef }): ReactElement { |
| 75 | const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as Partial<State> | undefined |
| 76 | if (state === undefined) { |
| 77 | return ( |
| 78 | <div className="sc-root"> |
| 79 | <div className="sc-bar"> |
| 80 | <span className="sc-status">Loading…</span> |
| 81 | </div> |
| 82 | </div> |
| 83 | ) |
| 84 | } |
| 85 | return <Screener panel={panel} initial={state} /> |
| 86 | } |
| 87 | |
| 88 | function Screener({ panel, initial }: { panel: PanelRef; initial: Partial<State> }): ReactElement { |
| 89 | const bridge = useBridge() |
| 90 | const ref = useMemo(() => ({ id: panel.id, workspaceId: panel.workspaceId }), [panel.id, panel.workspaceId]) |
| 91 | // The list and the sort change together with the page, in one write (write, below), so these four are only read here. |
| 92 | const [filters] = usePanelState<Filter[]>(panel, 'filters', initial.filters ?? NONE) |
| 93 | const [tickers] = usePanelState<string[]>(panel, 'tickers', initial.tickers ?? NONE) |
| 94 | const [includeUnlisted] = usePanelState<boolean>(panel, 'includeUnlisted', initial.includeUnlisted ?? false) |
| 95 | const [sort] = usePanelState<Sort>(panel, 'sort', initial.sort ?? DEFAULT_SORT) |
| 96 | const [page, setPage] = usePanelState<number>(panel, 'page', initial.page ?? 0) |
| 97 | const [columns, setColumns] = usePanelState<string[]>(panel, 'columns', initial.columns ?? DEFAULT_COLUMNS) |
| 98 | const [criteria, setCriteria] = usePanelState<Criterion[]>(panel, 'criteria', initial.criteria ?? NONE) |
| 99 | // A key that holds null reads as its initial value, so "no run" is the initial value: a run that |
| 100 | // is in the state arrives with the first push, a moment later, and nothing acts on its absence. |
| 101 | const [run, setRun] = usePanelState<Run | null>(panel, 'run', null) |
| 102 | const [verdicts, setVerdicts] = usePanelState<Verdict[]>(panel, 'verdicts', initial.verdicts ?? NONE) |
| 103 | const [open, setOpen] = usePanelState<string>(panel, 'open', initial.open ?? '') |
| 104 | const [dialog, setDialog] = useState<'filters' | 'columns' | null>(null) |
| 105 | |
| 106 | // The connections' statuses, so a run asked for while the server is still connecting starts once |
| 107 | // it is ready instead of failing. |
| 108 | const connections = useData('connections') as Record<string, { status?: string }> | undefined |
| 109 | const connectionsKey = JSON.stringify(Object.values(connections ?? {}).map((c) => c.status)) |
| 110 | |
| 111 | // The fields are the server's: what can be filtered, how, and in which unit a number reads. |
| 112 | const guide = useData('screener-mcp/guide', FIELDS) |
| 113 | const fields = useMemo(() => readFields(guide.data), [guide.data]) |
| 114 | const fieldMap = useMemo(() => new Map(fields.map((field) => [field.field, field])), [fields]) |
| 115 | const fieldsError = guide.error ?? (guide.text !== undefined ? 'This server\'s guide does not list its fields as rows.' : null) |
| 116 | |
| 117 | // The screen. The server keeps the list in a session, so what one call sends depends on what the |
| 118 | // session already holds (screenArgs). The arguments are made when what is asked for changes, not |
| 119 | // when an answer lands: the session id an answer brings is for the next call and must not cause one. |
| 120 | const session = useRef<string | null>(initial.sessionId ?? null) |
| 121 | const echo = useRef<Echo | null>(null) |
| 122 | const [again, setAgain] = useState(0) |
| 123 | const asked = JSON.stringify([filters, tickers, includeUnlisted, sort, page, columns, again]) |
| 124 | const args = useMemo( |
| 125 | () => screenArgs({ filters, tickers, includeUnlisted, sort, page, columns, sessionId: session.current }, echo.current, PAGE), |
| 126 | [asked], |
| 127 | ) |
| 128 | const { data, loading, error, meta } = useData('screener-mcp/screen', args) |
| 129 | const screen = useMemo(() => readScreen(meta), [meta]) |
| 130 | const rows = data ?? NONE |
| 131 | /** The session holds the list on screen and no call is out: what a run would freeze is what the user sees. */ |
| 132 | const settled = !loading && !error && screen !== null && inSync({ filters, tickers, includeUnlisted, sessionId: screen.sessionId }, echoOf(screen)) |
| 133 | |
| 134 | useEffect(() => { |
| 135 | if (!screen?.sessionId) return |
| 136 | echo.current = echoOf(screen) |
| 137 | if (session.current === screen.sessionId) return |
| 138 | session.current = screen.sessionId |
| 139 | void bridge.setState(ref, ['sessionId'], screen.sessionId) |
| 140 | }, [bridge, ref, screen]) |
| 141 | |
| 142 | // A session the server no longer has (another server, a database set up again): start a new one. |
| 143 | useEffect(() => { |
| 144 | if (!error || !session.current || !/No screening session/.test(error)) return |
| 145 | session.current = null |
| 146 | echo.current = null |
| 147 | void bridge.setState(ref, ['sessionId'], null) |
| 148 | setAgain((n) => n + 1) |
| 149 | }, [bridge, ref, error]) |
| 150 | |
| 151 | // A list that got shorter than the page it is on, as when the orchestrator sets filters and not the page. |
| 152 | useEffect(() => { |
| 153 | if (screen && !loading && page > 0 && page * PAGE >= screen.count) setPage(0) |
| 154 | }, [screen, loading, page, setPage]) |
| 155 | |
| 156 | /** |
| 157 | * Several keys in one write, so a change of two things is one re-run and not two. The rest of the |
| 158 | * state is read from the app at that moment, not from this render: a run's id that landed a |
| 159 | * moment ago must not be written over by what the view knew before it. |
| 160 | */ |
| 161 | async function write(patch: Partial<State>): Promise<void> { |
| 162 | const current = (await bridge.get(`workspaces/${ref.workspaceId}/panels/${ref.id}/state`)) as Partial<State> | undefined |
| 163 | await bridge.setState(ref, [], { ...current, ...patch }) |
| 164 | } |
| 165 | |
| 166 | /** A new list starts at its first page, whichever control asked for it. */ |
| 167 | function changeListed(next: Listed): void { |
| 168 | void write({ ...next, page: 0 }) |
| 169 | } |
| 170 | |
| 171 | /** A failed start is asked for again; a run that could not be read is read again. */ |
| 172 | function retry(): void { |
| 173 | if (!run?.id) { |
| 174 | setRun({}) |
| 175 | return |
| 176 | } |
| 177 | setRun({ id: run.id, status: 'running' }) |
| 178 | setReadAgain((n) => n + 1) |
| 179 | } |
| 180 | |
| 181 | function toggleSort(field: string): void { |
| 182 | void write({ sort: sort.field === field && sort.dir === 'desc' ? { field, dir: 'asc' } : { field, dir: 'desc' }, page: 0 }) |
| 183 | } |
| 184 | |
| 185 | // The qualitative stage. A `run` with no id is the request (needsStart); the view starts it over |
| 186 | // the session's list, once that list is the one on screen. It costs model calls for every company, |
| 187 | // so nothing here may start one twice: the latch holds from the call until the run's id is in the state. |
| 188 | const starting = useRef(false) |
| 189 | useEffect(() => { |
| 190 | if (run === null || run.id) starting.current = false |
| 191 | if (!needsStart(criteria, run) || starting.current || !screen || !settled) return |
| 192 | if (!screen.ready || !screen.sessionId) { |
| 193 | setRun({ status: 'error', error: overLimit(screen) }) |
| 194 | return |
| 195 | } |
| 196 | starting.current = true |
| 197 | void (async () => { |
| 198 | try { |
| 199 | const result = await bridge.runSource('screener-mcp/qualitative-start', { session_id: screen.sessionId, criteria }) |
| 200 | const started = readStart(result.kind === 'text' ? result.text : JSON.stringify(result.meta)) |
| 201 | // Written whether or not the view is still mounted: a run that was started must not be lost. |
| 202 | setRun({ id: started.runId, status: 'running' }) |
| 203 | } catch (err) { |
| 204 | starting.current = false |
| 205 | const text = err instanceof Error ? err.message : String(err) |
| 206 | // Not ready yet: the request stays as it is and this runs again when a connection changes. |
| 207 | if (/connection_unavailable/.test(text) && /connecting/.test(text)) return |
| 208 | setRun({ status: 'error', error: text }) |
| 209 | } |
| 210 | })() |
| 211 | }, [bridge, criteria, run, screen, settled, setRun, connectionsKey]) |
| 212 | |
| 213 | // A run is followed until it is done. The first read is the whole run, at once, so a panel that |
| 214 | // comes back to a run shows where it is; each one after waits on the server a few seconds and asks |
| 215 | // only for the companies not yet over (followArgs), so the loop is one small request in flight at a |
| 216 | // time, never a burst, and a company's answers are fetched once. Results come without evidence: |
| 217 | // that is asked for one company at a time, below. |
| 218 | const [snapshot, setSnapshot] = useState<RunSnapshot | null>(null) |
| 219 | const [readAgain, setReadAgain] = useState(0) |
| 220 | /** Each opened company's evidence, and the answer it was read for. */ |
| 221 | const evidence = useRef(new Map<string, { key: string; found: Evidenced }>()) |
| 222 | const runId = run?.id |
| 223 | const status = useRef(run?.status) |
| 224 | status.current = run?.status |
| 225 | const chosen = useRef(verdicts) |
| 226 | chosen.current = verdicts |
| 227 | useEffect(() => { |
| 228 | if (!runId) { |
| 229 | setSnapshot(null) |
| 230 | evidence.current.clear() |
| 231 | return |
| 232 | } |
| 233 | let live = true |
| 234 | void (async () => { |
| 235 | let seen: RunSnapshot | null = null |
| 236 | while (live) { |
| 237 | const asked = Date.now() |
| 238 | let next: RunSnapshot |
| 239 | try { |
| 240 | const read = await readRunFrom(bridge, followArgs(runId, seen, POLL_SECONDS)) |
| 241 | if (!live) return |
| 242 | next = seen ? mergeRun(seen, read) : read |
| 243 | // A run that ended while it was followed is read whole once more: the reads before this one each held some of its companies. |
| 244 | if (seen && next.status === 'done') { |
| 245 | next = await readRunFrom(bridge, followArgs(runId, null, 0)) |
| 246 | if (!live) return |
| 247 | } |
| 248 | } catch (err) { |
| 249 | if (!live) return |
| 250 | const text = err instanceof Error ? err.message : String(err) |
| 251 | if (/connection_unavailable/.test(text) && /connecting/.test(text)) return |
| 252 | setRun({ id: runId, status: 'error', error: text }) |
| 253 | return |
| 254 | } |
| 255 | setSnapshot(next) |
| 256 | const now = next.status === 'done' ? 'done' : 'running' |
| 257 | // A run that finishes shows who passed: until then the rows were only the list being read, and |
| 258 | // left as they are they read as the answer. A verdict filter somebody already chose stays. |
| 259 | if (status.current !== now && now === 'done' && chosen.current.length === 0) void write({ run: { id: runId, status: now }, verdicts: ['pass'] }) |
| 260 | else if (status.current !== now) setRun({ id: runId, status: now }) |
| 261 | if (next.status === 'done') return |
| 262 | // The pace is the server's, which holds each read after the first. One that answers sooner is not asked again at once. |
| 263 | if (seen) await sleep(POLL_SECONDS * 1000 - (Date.now() - asked)) |
| 264 | seen = next |
| 265 | } |
| 266 | })() |
| 267 | return () => { |
| 268 | live = false |
| 269 | } |
| 270 | }, [bridge, runId, readAgain, setRun, connectionsKey]) |
| 271 | |
| 272 | // The open company's evidence: one call for that company, when its row is opened and again when |
| 273 | // its answer changes. Quotes stay here, not in state: a company's worth is most of the output |
| 274 | // budget, and a list's worth is far past it. |
| 275 | const [shown, setShown] = useState<{ ticker: string; found: Evidenced | null; error: string | null } | null>(null) |
| 276 | const openResult = open ? snapshot?.results.find((result) => result.ticker === open) : undefined |
| 277 | const openKey = open && runId && snapshot ? `${runId}|${open}|${openResult?.status ?? ''}|${openResult?.verdict ?? ''}` : '' |
| 278 | useEffect(() => { |
| 279 | if (!openKey || !runId) { |
| 280 | setShown(null) |
| 281 | return |
| 282 | } |
| 283 | const kept = evidence.current.get(open) |
| 284 | setShown({ ticker: open, found: kept?.found ?? null, error: null }) |
| 285 | if (kept?.key === openKey) return |
| 286 | let live = true |
| 287 | void (async () => { |
| 288 | try { |
| 289 | const read = await readRunFrom(bridge, { run_id: runId, tickers: [open], evidence: true }) |
| 290 | if (!live) return |
| 291 | const company = read.results[0] |
| 292 | if (!company) throw new Error(`The run has no company with the ticker ${open}.`) |
| 293 | evidence.current.set(open, { key: openKey, found: { company, citations: read.citations } }) |
| 294 | setShown({ ticker: open, found: { company, citations: read.citations }, error: null }) |
| 295 | } catch (err) { |
| 296 | if (live) setShown({ ticker: open, found: null, error: err instanceof Error ? err.message : String(err) }) |
| 297 | } |
| 298 | })() |
| 299 | return () => { |
| 300 | live = false |
| 301 | } |
| 302 | }, [bridge, runId, open, openKey]) |
| 303 | |
| 304 | const results = useMemo(() => new Map((snapshot?.results ?? []).map((result) => [result.cik, result])), [snapshot]) |
| 305 | const visible = useMemo(() => { |
| 306 | if (!snapshot || verdicts.length === 0) return rows |
| 307 | return rows.filter((row) => { |
| 308 | const verdict = results.get(Number(row.cik))?.verdict |
| 309 | return verdict !== null && verdict !== undefined && verdicts.includes(verdict) |
| 310 | }) |
| 311 | }, [rows, snapshot, results, verdicts]) |
| 312 | |
| 313 | usePublish(panel, { |
| 314 | ...fitOutput({ |
| 315 | session_id: screen?.sessionId ?? null, |
| 316 | universe: screen?.universe ?? 0, |
| 317 | count: screen?.count ?? 0, |
| 318 | applied_filters: publishedFilters(screen), |
| 319 | tickers: visible.map((row) => row.ticker).filter((ticker): ticker is string => typeof ticker === 'string'), |
| 320 | unknown_tickers: screen?.unknownTickers ?? [], |
| 321 | sort, |
| 322 | qualitative_ready: screen?.ready ?? false, |
| 323 | qualitative_max: screen?.max ?? 0, |
| 324 | qualitative: run |
| 325 | ? { |
| 326 | run_id: run.id ?? null, |
| 327 | status: stageOf(criteria, run), |
| 328 | progress: snapshot?.progress ?? null, |
| 329 | ...(snapshot?.phase ? { earnings_calls: snapshot.phase } : {}), |
| 330 | ...(snapshot ? { passed: passedTickers(snapshot) } : {}), |
| 331 | ...(run.error ? { error: run.error } : {}), |
| 332 | } |
| 333 | : undefined, |
| 334 | open, |
| 335 | }), |
| 336 | }) |
| 337 | // The open company's evidence as text, for a model to quote: every quote carries its [^id]. |
| 338 | usePublishText(panel, shown?.found && runId ? evidenceText(shown.found.company, snapshot?.criteria ?? criteria, shown.found.citations, runId) : null) |
| 339 | |
| 340 | const hasNext = (page + 1) * PAGE < (screen?.count ?? 0) |
| 341 | const listed: Listed = { filters, tickers, includeUnlisted } |
| 342 | |
| 343 | return ( |
| 344 | <div className="sc-root"> |
| 345 | <div className="sc-bar sc-top"> |
| 346 | <FilterBar listed={listed} fields={fieldMap} onChange={changeListed} onOpen={() => setDialog('filters')} onColumns={() => setDialog('columns')} /> |
| 347 | <span className="sc-count"> |
| 348 | {visible.length} shown of {(screen?.count ?? 0).toLocaleString('en-US')} |
| 349 | </span> |
| 350 | </div> |
| 351 | |
| 352 | <Funnel listed={listed} screen={screen} loading={loading} fields={fieldMap}> |
| 353 | <RunProgress criteria={criteria} run={run} snapshot={snapshot} /> |
| 354 | </Funnel> |
| 355 | |
| 356 | <QualitativeBar |
| 357 | criteria={criteria} |
| 358 | onCriteria={setCriteria} |
| 359 | run={run} |
| 360 | snapshot={snapshot} |
| 361 | screen={screen} |
| 362 | settled={settled} |
| 363 | verdicts={verdicts} |
| 364 | onVerdicts={setVerdicts} |
| 365 | onRun={() => setRun({})} |
| 366 | onRetry={retry} |
| 367 | onClear={() => void write({ run: null, verdicts: [], open: '' })} |
| 368 | /> |
| 369 | |
| 370 | <Table |
| 371 | rows={visible} |
| 372 | columns={shownColumns(filters, sort, columns)} |
| 373 | fields={fieldMap} |
| 374 | sort={sort} |
| 375 | onSort={toggleSort} |
| 376 | loading={loading && data === undefined} |
| 377 | stale={loading && data !== undefined} |
| 378 | empty={snapshot && verdicts.length > 0 && rows.length > 0 ? `No company on this page is ${verdicts.join(' or ')}. The verdict buttons above show the others.` : 'No companies match.'} |
| 379 | run={snapshot ? { criteria: snapshot.criteria, results } : null} |
| 380 | open={open} |
| 381 | onOpen={(ticker) => setOpen(ticker === open ? '' : ticker)} |
| 382 | /> |
| 383 | |
| 384 | {open && runId && ( |
| 385 | <EvidencePane |
| 386 | ticker={open} |
| 387 | evidence={shown?.ticker === open ? shown.found : null} |
| 388 | error={shown?.ticker === open ? shown.error : null} |
| 389 | questions={snapshot?.criteria ?? criteria} |
| 390 | onClose={() => setOpen('')} |
| 391 | onLink={(url) => void bridge.openLink(url)} |
| 392 | /> |
| 393 | )} |
| 394 | |
| 395 | <div className="sc-bar sc-foot"> |
| 396 | <button type="button" className="sc-btn" disabled={page === 0} onClick={() => setPage(page - 1)}> |
| 397 | Prev |
| 398 | </button> |
| 399 | <button type="button" className="sc-btn" disabled={!hasNext} onClick={() => setPage(page + 1)}> |
| 400 | Next |
| 401 | </button> |
| 402 | <span className="sc-status">Page {page + 1}</span> |
| 403 | {/* The source's own words, which is how a refused filter or a server that is not running reaches the user. */} |
| 404 | <span className={error ? 'sc-error' : 'sc-status'} style={{ marginLeft: 'auto' }}> |
| 405 | {error ?? (loading ? 'Loading…' : '')} |
| 406 | </span> |
| 407 | </div> |
| 408 | |
| 409 | {dialog === 'filters' && ( |
| 410 | <FilterDialog |
| 411 | listed={listed} |
| 412 | fields={fields} |
| 413 | fieldsError={fieldsError} |
| 414 | onApply={(next) => { |
| 415 | changeListed(next) |
| 416 | setDialog(null) |
| 417 | }} |
| 418 | onClose={() => setDialog(null)} |
| 419 | /> |
| 420 | )} |
| 421 | {dialog === 'columns' && ( |
| 422 | <ColumnsDialog |
| 423 | columns={columns} |
| 424 | fields={fields} |
| 425 | fieldsError={fieldsError} |
| 426 | onApply={(next) => { |
| 427 | setColumns(next) |
| 428 | setDialog(null) |
| 429 | }} |
| 430 | onClose={() => setDialog(null)} |
| 431 | /> |
| 432 | )} |
| 433 | </div> |
| 434 | ) |
| 435 | } |