Jaspers Hub

jaspers / screener-mcp

pluginOfficialReviewed★ 0↓ 33

Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then runs yes/no criteria against filings with cited quotes.

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