Download app/frontend/src/components/Intake.tsx from ml-intern-explorers/primarydraft: direct link, hf CLI and curl.
- Browser
- Download file 16.6 kB
-
https://huggingface.co/spaces/ml-intern-explorers/primarydraft/resolve/main/app/frontend/src/components/Intake.tsx
- Command line
-
hf download hf://spaces/ml-intern-explorers/primarydraft/app/frontend/src/components/Intake.tsx
-
curl -L -o Intake.tsx https://huggingface.co/spaces/ml-intern-explorers/primarydraft/resolve/main/app/frontend/src/components/Intake.tsx
16.6 kB
| import { useEffect, useMemo, useRef, useState } from "react"; | |
| import { | |
| Buildings, MagnifyingGlass, FilePdf, Database, ArrowRight, | |
| DownloadSimple, WarningCircle, CheckCircle, | |
| } from "@phosphor-icons/react"; | |
| import { api, type Company, type RunSummary, ApiError } from "../lib/api"; | |
| import { ErrorNote, TableSkeleton, Empty, Spinner, Note } from "./common"; | |
| interface Hit { | |
| scrip_code: string; | |
| name: string; | |
| issuer_name: string; | |
| isin: string | null; | |
| industry: string; | |
| group: string; | |
| market_cap: string | null; | |
| is_sme: boolean; | |
| prepared: boolean; | |
| has_filing?: boolean | null; | |
| filing_year?: string | null; | |
| } | |
| interface IngestEvent { stage: string; detail: string; pct: number; at: number } | |
| export function Intake({ onStarted, embedded = false }: | |
| { onStarted: (run: RunSummary) => void; embedded?: boolean }) { | |
| const [q, setQ] = useState(""); | |
| const [scope, setScope] = useState<"sme" | "all">("sme"); | |
| const [hits, setHits] = useState<Hit[] | null>(null); | |
| const [universe, setUniverse] = useState<Record<string, number> | null>(null); | |
| const [prepared, setPrepared] = useState<Record<string, Company>>({}); | |
| const [error, setError] = useState<string | null>(null); | |
| const [searchErr, setSearchErr] = useState<string | null>(null); | |
| const [sel, setSel] = useState<Hit | null>(null); | |
| const [starting, setStarting] = useState(false); | |
| // live ingestion | |
| const [job, setJob] = useState<{ id: string; status: string } | null>(null); | |
| const [events, setEvents] = useState<IngestEvent[]>([]); | |
| const [ingestErr, setIngestErr] = useState<string | null>(null); | |
| const es = useRef<EventSource | null>(null); | |
| useEffect(() => { | |
| api.companies() | |
| .then((r) => setPrepared(Object.fromEntries(r.companies.map((c) => [c.scrip_code, c])))) | |
| .catch(() => { /* live search still works without the prepared index */ }); | |
| }, []); | |
| // Debounced live search against the real BSE master. | |
| useEffect(() => { | |
| const t = setTimeout(() => { | |
| setSearchErr(null); | |
| setHits(null); | |
| fetch(`/api/search?q=${encodeURIComponent(q)}&scope=${scope}&limit=40`) | |
| .then(async (r) => { | |
| if (!r.ok) throw new Error((await r.json()).detail ?? r.statusText); | |
| return r.json(); | |
| }) | |
| .then((d) => { setHits(d.results); setUniverse(d.universe); }) | |
| .catch((e) => setSearchErr(String(e.message ?? e))); | |
| }, q ? 260 : 0); | |
| return () => clearTimeout(t); | |
| }, [q, scope]); | |
| useEffect(() => () => es.current?.close(), []); | |
| const selPrepared = sel ? prepared[sel.scrip_code] : undefined; | |
| const isReady = Boolean(sel && (sel.prepared || job?.status === "done")); | |
| const ingest = () => { | |
| if (!sel) return; | |
| setIngestErr(null); | |
| setEvents([]); | |
| setJob(null); | |
| api.ingest(sel.scrip_code, sel.name) | |
| .then((j) => { | |
| setJob({ id: j.id, status: j.status }); | |
| es.current?.close(); | |
| const src = new EventSource(`/api/ingest/${j.id}/stream`); | |
| es.current = src; | |
| src.addEventListener("progress", (e) => | |
| setEvents((p) => [...p, JSON.parse((e as MessageEvent).data)])); | |
| src.addEventListener("done", (e) => { | |
| const d = JSON.parse((e as MessageEvent).data); | |
| setJob({ id: d.id, status: "done" }); | |
| setPrepared((p) => ({ ...p, [d.summary.scrip_code]: d.summary as Company })); | |
| setHits((h) => h?.map((x) => | |
| x.scrip_code === d.summary.scrip_code ? { ...x, prepared: true } : x) ?? null); | |
| src.close(); | |
| }); | |
| src.addEventListener("failed", (e) => { | |
| const d = JSON.parse((e as MessageEvent).data); | |
| setJob({ id: d.id, status: "failed" }); | |
| setIngestErr(d.error); | |
| src.close(); | |
| }); | |
| src.onerror = () => src.close(); | |
| }) | |
| .catch((e) => setIngestErr(e instanceof ApiError ? e.message : String(e))); | |
| }; | |
| const start = async () => { | |
| if (!sel) return; | |
| setStarting(true); | |
| setError(null); | |
| try { | |
| onStarted(await api.createRun(sel.scrip_code)); | |
| } catch (e) { | |
| setError(e instanceof ApiError ? e.message : String(e)); | |
| setStarting(false); | |
| } | |
| }; | |
| const pct = events.length ? events[events.length - 1].pct : 0; | |
| return ( | |
| <> | |
| {!embedded && ( | |
| <div className="page-head"> | |
| <h1 className="page">Find an issuer</h1> | |
| <p className="lede"> | |
| Search every company listed on BSE. Records already read are available at once; any | |
| other issuer is ingested live, by downloading its actual annual report and parsing | |
| it. | |
| </p> | |
| </div> | |
| )} | |
| {error && <div style={{ marginBottom: 14 }}><ErrorNote message={error} /></div>} | |
| <div className="grid cols-sidebar"> | |
| <section className="panel"> | |
| <div className="panel-h"> | |
| <Buildings size={16} /> | |
| <h2>BSE issuers</h2> | |
| {universe && ( | |
| <span className="count"> | |
| {scope === "sme" ? universe.sme : universe.total} searchable · | |
| {" "}{universe.prepared} already read | |
| </span> | |
| )} | |
| <div className="filters" style={{ marginLeft: "auto" }}> | |
| <button className="filter" aria-pressed={scope === "sme"} | |
| onClick={() => setScope("sme")}>SME platform</button> | |
| <button className="filter" aria-pressed={scope === "all"} | |
| onClick={() => setScope("all")}>all BSE</button> | |
| </div> | |
| </div> | |
| <div className="panel-b" style={{ paddingBottom: 12 }}> | |
| <label htmlFor="co-search" style={{ display: "none" }}>Search issuers</label> | |
| <div style={{ position: "relative" }}> | |
| <MagnifyingGlass size={15} style={{ | |
| position: "absolute", left: 11, top: "50%", | |
| transform: "translateY(-50%)", color: "var(--text-3)", | |
| }} /> | |
| <input | |
| id="co-search" | |
| className="ctl search" | |
| style={{ paddingLeft: 32 }} | |
| placeholder="Search by company name, BSE scrip code, ISIN or industry" | |
| value={q} | |
| onChange={(e) => setQ(e.target.value)} | |
| autoFocus | |
| /> | |
| </div> | |
| </div> | |
| <div className="co-list"> | |
| {searchErr && <div style={{ padding: 16 }}><ErrorNote message={searchErr} /></div>} | |
| {!hits && !searchErr && <TableSkeleton rows={9} />} | |
| {hits?.length === 0 && ( | |
| <Empty icon={<MagnifyingGlass size={26} />} title="No issuer matches that search"> | |
| Try a company name, a BSE scrip code, an ISIN, or switch to all of BSE. | |
| </Empty> | |
| )} | |
| {hits?.map((c) => { | |
| const p = prepared[c.scrip_code]; | |
| return ( | |
| <button | |
| key={c.scrip_code} | |
| className="co" | |
| aria-pressed={sel?.scrip_code === c.scrip_code} | |
| onClick={() => { | |
| setSel(c); | |
| setJob(null); setEvents([]); setIngestErr(null); | |
| }} | |
| > | |
| <span> | |
| <span className="co-name">{c.name}</span> | |
| <span className="co-meta"> | |
| scrip {c.scrip_code} | |
| {p?.cin ? ` · ${p.cin}` : c.isin ? ` · ${c.isin}` : ""} | |
| {c.industry ? ` · ${c.industry}` : ""} | |
| </span> | |
| </span> | |
| <span className="co-tags"> | |
| {c.is_sme && <span className="tag tag-info">SME</span>} | |
| {c.prepared || p ? ( | |
| <span className="tag tag-ok">read</span> | |
| ) : c.has_filing === false ? ( | |
| <span className="tag">no filing yet</span> | |
| ) : c.has_filing ? ( | |
| <span className="tag tag-warn"> | |
| filing {c.filing_year ?? ""} | |
| </span> | |
| ) : ( | |
| <span className="tag">not read yet</span> | |
| )} | |
| </span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </section> | |
| <aside style={{ display: "grid", gap: 14 }}> | |
| {!sel ? ( | |
| <div className="panel"> | |
| <Empty icon={<Buildings size={26} />} title="No issuer selected"> | |
| Choose an issuer to see the source records PrimaryDraft will read. | |
| </Empty> | |
| </div> | |
| ) : ( | |
| <> | |
| <section className="panel"> | |
| <div className="panel-h"><h2>{sel.name}</h2></div> | |
| <div className="panel-b" style={{ display: "grid", gap: 10 }}> | |
| <Row k="BSE scrip" v={sel.scrip_code} /> | |
| <Row k="CIN" v={selPrepared?.cin ?? "read from the filing on ingest"} /> | |
| <Row k="ISIN" v={sel.isin ?? "not stated"} /> | |
| <Row k="Industry" v={sel.industry || "not stated"} /> | |
| <Row k="Platform" v={sel.is_sme ? `BSE SME (group ${sel.group})` | |
| : `main board (group ${sel.group})`} /> | |
| {!sel.is_sme && ( | |
| <Note tone="warn"> | |
| This issuer is on the main board, not the SME platform. The engine will | |
| still run, and the SME eligibility rules will report accordingly. | |
| </Note> | |
| )} | |
| </div> | |
| </section> | |
| {isReady ? ( | |
| <> | |
| <section className="panel"> | |
| <div className="panel-h"><h2>Source records</h2></div> | |
| <div className="panel-b" style={{ display: "grid", gap: 12 }}> | |
| <Source | |
| icon={<FilePdf size={17} />} | |
| title={`Annual Report ${selPrepared?.year ?? ""}`} | |
| detail={selPrepared | |
| ? `${selPrepared.pages} pages, filed with BSE. ${selPrepared.pl_fields} profit and loss fields and ${selPrepared.bs_fields} balance sheet fields were machine-readable.` | |
| : "Read from the issuer's filing on BSE."} | |
| /> | |
| <Source | |
| icon={<Database size={17} />} | |
| title="BSE scrip master" | |
| detail={`Live exchange API. Scrip ${sel.scrip_code}, ISIN, face value, industry classification.`} | |
| /> | |
| {selPrepared && (selPrepared.identities?.length ?? 0) > 0 ? ( | |
| <Note> | |
| <b>{selPrepared.identities.length} accounting identities close</b> on | |
| the statements read from this filing. A statement whose arithmetic does | |
| not close is withheld rather than published. | |
| <ul style={{ margin: "7px 0 0", paddingLeft: 17 }}> | |
| {selPrepared.identities.map((i) => ( | |
| <li key={i} className="mono" style={{ fontSize: 11 }}>{i}</li> | |
| ))} | |
| </ul> | |
| </Note> | |
| ) : selPrepared ? ( | |
| <Note tone="warn"> | |
| No accounting identity closed on this filing, so the financial | |
| statements were withheld. The ledger will be thin and the engine will | |
| say so. | |
| </Note> | |
| ) : null} | |
| </div> | |
| </section> | |
| <button className="btn btn-primary" onClick={start} disabled={starting} | |
| style={{ justifyContent: "center", padding: "11px 18px" }}> | |
| {starting ? <><Spinner /> Reading records</> | |
| : <>Build disclosure ledger <ArrowRight size={15} /></>} | |
| </button> | |
| </> | |
| ) : ( | |
| <section className="panel"> | |
| <div className="panel-h"><h2>Ingest from BSE</h2></div> | |
| <div className="panel-b" style={{ display: "grid", gap: 12 }}> | |
| {!job && !ingestErr && ( | |
| sel.has_filing === false ? ( | |
| <Note tone="warn"> | |
| This issuer has no annual report filed on BSE, so there are no records | |
| to read. Recently listed SME companies often have not filed their first | |
| one yet. Pick an issuer showing a filing year. | |
| </Note> | |
| ) : ( | |
| <> | |
| <Note tone="quiet"> | |
| This issuer has not been read yet. PrimaryDraft will download its | |
| actual annual report{sel.filing_year ? ` (${sel.filing_year})` : ""} from | |
| BSE and parse it. Filings run to a few hundred pages, so this takes a | |
| little time. | |
| </Note> | |
| <button className="btn btn-primary" onClick={ingest} | |
| style={{ justifyContent: "center" }}> | |
| <DownloadSimple size={15} /> Read this issuer's filing | |
| </button> | |
| </> | |
| ) | |
| )} | |
| {job && ( | |
| <> | |
| <div className="bar"><i style={{ width: `${pct}%` }} /></div> | |
| <div style={{ display: "grid", gap: 6 }}> | |
| {events.map((e, i) => ( | |
| <div key={i} style={{ | |
| display: "grid", gridTemplateColumns: "auto 46px 1fr", | |
| gap: 9, alignItems: "baseline", fontSize: 12, | |
| }}> | |
| <span style={{ color: "var(--accent)" }}> | |
| {e.stage === "failed" | |
| ? <WarningCircle size={13} weight="fill" /> | |
| : i === events.length - 1 && job.status === "running" | |
| ? <Spinner /> | |
| : <CheckCircle size={13} weight="fill" />} | |
| </span> | |
| <span className="mono" style={{ fontSize: 10.5, color: "var(--text-3)" }}> | |
| {e.at.toFixed(1)}s | |
| </span> | |
| <span style={{ color: "var(--text-2)", lineHeight: 1.45 }}> | |
| {e.detail} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| {ingestErr && ( | |
| <> | |
| <ErrorNote message={ingestErr} /> | |
| <Note tone="quiet"> | |
| This is the honest outcome, not a bug. A filing with no text layer | |
| cannot be cited, and PrimaryDraft will not build a ledger it cannot | |
| defend. Pick another issuer. | |
| </Note> | |
| </> | |
| )} | |
| </div> | |
| </section> | |
| )} | |
| </> | |
| )} | |
| </aside> | |
| </div> | |
| </> | |
| ); | |
| } | |
| function Row({ k, v }: { k: string; v: string }) { | |
| return ( | |
| <div style={{ display: "grid", gridTemplateColumns: "88px 1fr", gap: 10, fontSize: 12.5 }}> | |
| <span style={{ color: "var(--text-2)" }}>{k}</span> | |
| <span className="mono" style={{ wordBreak: "break-all" }}>{v}</span> | |
| </div> | |
| ); | |
| } | |
| function Source({ icon, title, detail }: { icon: React.ReactNode; title: string; detail: string }) { | |
| return ( | |
| <div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: 10 }}> | |
| <span style={{ color: "var(--accent)", marginTop: 1 }}>{icon}</span> | |
| <span> | |
| <div style={{ fontSize: 12.5, fontWeight: 500 }}>{title}</div> | |
| <div style={{ fontSize: 11.5, color: "var(--text-2)", lineHeight: 1.5, marginTop: 2 }}> | |
| {detail} | |
| </div> | |
| </span> | |
| </div> | |
| ); | |
| } | |