AI assistant (config-gated, validated SQL only), EXPLAIN in sandbox
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 changed files +1,239 −12
added
apps/web/README.md
+43 −0
@@ -0,0 +1,43 @@ | |||||
| 1 | +# FinData OS — web console | ||||
| 2 | + | ||||
| 3 | +Private operator console for the FinData OS lakehouse. Next.js 15 (App Router) + TypeScript + Tailwind CSS v4, built as a **static export** and served by the Python API — no Node process in production. | ||||
| 4 | + | ||||
| 5 | +## Develop | ||||
| 6 | + | ||||
| 7 | +```bash | ||||
| 8 | +cd apps/web | ||||
| 9 | +pnpm install | ||||
| 10 | +pnpm dev # http://localhost:3000 — /api/* is proxied to http://127.0.0.1:8600 (next.config.ts rewrites, dev only) | ||||
| 11 | +pnpm typecheck # tsc --noEmit | ||||
| 12 | +``` | ||||
| 13 | + | ||||
| 14 | +Start the API locally (`uv run uvicorn findata_api.main:app --port 8600`) before using the dev server; the console authenticates with the API's cookie session and sends `X-CSRF-Token` on every non-GET request. | ||||
| 15 | + | ||||
| 16 | +## Build & serve | ||||
| 17 | + | ||||
| 18 | +```bash | ||||
| 19 | +pnpm build # → apps/web/out/ (index.html + one directory per route, trailing slashes) | ||||
| 20 | +``` | ||||
| 21 | + | ||||
| 22 | +`services/api/src/findata_api/main.py` mounts `apps/web/out` (or `FINDATA_WEB_DIST`) with `StaticFiles(html=True)`, so `/datasets/` serves `out/datasets/index.html`, and the console is same-origin with `/api/v1`. The CSP is `default-src 'self'` (inline styles allowed, inline scripts not); the bundle contains no inline scripts, external fonts or CDNs. | ||||
| 23 | + | ||||
| 24 | +Detail pages use query strings instead of dynamic segments (static export cannot pre-render unknown ids): `/datasets/detail/?id=…`, `/symbols/detail/?symbol=…`. | ||||
| 25 | + | ||||
| 26 | +## Layout | ||||
| 27 | + | ||||
| 28 | +``` | ||||
| 29 | +src/lib/api.ts fetch wrapper (same-origin cookies, CSRF header, error normalisation) + API types | ||||
| 30 | +src/lib/auth.tsx /auth/me context, redirects to /login/ (401) or the MFA step | ||||
| 31 | +src/lib/hooks.ts useAsync, useDebounced, useQueryParam, useInterval, useLocalStorage | ||||
| 32 | +src/lib/format.ts bytes, thin-space numbers, YYYY-MM-DD HH:mm:ssZ, CSV | ||||
| 33 | +src/components/ Shell (sidebar ≥1024px, bottom tabs + drawer on mobile), DataTable (cards <640px), charts, dialogs, toast | ||||
| 34 | +src/app/<route>/page.tsx one client page per route | ||||
| 35 | +``` | ||||
| 36 | + | ||||
| 37 | +Routes: `/` overview · `/login/` · `/datasets/` (+`detail/`) · `/symbols/` (+`detail/`) · `/coverage/` · `/bars/` · `/ingestion/` · `/quality/` · `/storage/` · `/keys/` · `/playground/` · `/exports/` · `/fundamentals/` · `/docs/` · `/audit/` · `/settings/`. | ||||
| 38 | + | ||||
| 39 | +Owner-only pages (ingestion, storage, keys, audit, environment settings) are hidden from the navigation for other roles and show a notice if opened directly; the API enforces the `admin` scope regardless. | ||||
| 40 | + | ||||
| 41 | +## Design | ||||
| 42 | + | ||||
| 43 | +Brand-neutral palette validated with the `dataviz` skill (categorical blue/orange/aqua, sequential blue ramp for the coverage heatmap, fixed status colours with icon + label), light and dark via `prefers-color-scheme`, system font stack. Charts: `recharts` (one axis per chart, 2 px lines, ≤24 px bars, hover tooltips) and plain SVG for the heatmap. | ||||
modified
apps/web/package.json
+1 −1
@@ -5,7 +5,7 @@ | |||||
| 5 | "description": "FinData OS — private web console (static export served by the Python API)", | 5 | "description": "FinData OS — private web console (static export served by the Python API)", | ||
| 6 | "scripts": { | 6 | "scripts": { | ||
| 7 | "dev": "next dev -p 3000", | 7 | "dev": "next dev -p 3000", | ||
| 8 | − "build": "next build", | 8 | + "build": "next build && node scripts/externalize-inline-scripts.mjs", | ||
| 9 | "typecheck": "tsc --noEmit", | 9 | "typecheck": "tsc --noEmit", | ||
| 10 | "lint": "next lint" | 10 | "lint": "next lint" | ||
| 11 | }, | 11 | }, | ||
added
apps/web/scripts/externalize-inline-scripts.mjs
+66 −0
@@ -0,0 +1,66 @@ | |||||
| 1 | +#!/usr/bin/env node | ||||
| 2 | +/** | ||||
| 3 | + * Post-build step for the static export. | ||||
| 4 | + * | ||||
| 5 | + * The API serves the console with `Content-Security-Policy: script-src 'self'` (no 'unsafe-inline'). | ||||
| 6 | + * Next.js App Router emits the React Server Components payload as inline | ||||
| 7 | + * `<script>self.__next_f.push([...])</script>` tags, which such a CSP blocks — hydration would never run. | ||||
| 8 | + * | ||||
| 9 | + * This script rewrites every `out/**\/*.html`: the inline scripts (all of them are `__next_f` pushes) are | ||||
| 10 | + * concatenated, in document order, into one external file `out/_next/static/flight/<hash>.js`, and a single | ||||
| 11 | + * blocking `<script src=…>` is inserted where the first inline script was. Pushing the flight data from an | ||||
| 12 | + * external file is order-independent for Next's client runtime (it buffers pushes until the app chunk takes | ||||
| 13 | + * over and closes the stream on DOMContentLoaded, which a blocking script always precedes). | ||||
| 14 | + * | ||||
| 15 | + * Fails loudly if an inline script that is not a `__next_f` push is found, so the CSP contract stays explicit. | ||||
| 16 | + */ | ||||
| 17 | +import { createHash } from "node:crypto"; | ||||
| 18 | +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; | ||||
| 19 | +import { dirname, join, relative } from "node:path"; | ||||
| 20 | +import { fileURLToPath } from "node:url"; | ||||
| 21 | + | ||||
| 22 | +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); | ||||
| 23 | +const out = join(root, "out"); | ||||
| 24 | +const flightDir = join(out, "_next", "static", "flight"); | ||||
| 25 | + | ||||
| 26 | +function walk(dir, acc = []) { | ||||
| 27 | + for (const name of readdirSync(dir)) { | ||||
| 28 | + const p = join(dir, name); | ||||
| 29 | + const st = statSync(p); | ||||
| 30 | + if (st.isDirectory()) { | ||||
| 31 | + if (name === "_next") continue; | ||||
| 32 | + walk(p, acc); | ||||
| 33 | + } else if (name.endsWith(".html")) acc.push(p); | ||||
| 34 | + } | ||||
| 35 | + return acc; | ||||
| 36 | +} | ||||
| 37 | + | ||||
| 38 | +const INLINE = /<script>([\s\S]*?)<\/script>/g; | ||||
| 39 | +let files = 0, scripts = 0, bytes = 0; | ||||
| 40 | +mkdirSync(flightDir, { recursive: true }); | ||||
| 41 | + | ||||
| 42 | +for (const file of walk(out)) { | ||||
| 43 | + const html = readFileSync(file, "utf8"); | ||||
| 44 | + const chunks = []; | ||||
| 45 | + let first = -1; | ||||
| 46 | + for (const m of html.matchAll(INLINE)) { | ||||
| 47 | + const body = m[1]; | ||||
| 48 | + if (!/^\s*\(?self\.__next_f/.test(body)) { | ||||
| 49 | + throw new Error(`${relative(root, file)}: unexpected inline <script> (not a __next_f push):\n${body.slice(0, 200)}`); | ||||
| 50 | + } | ||||
| 51 | + if (first < 0) first = m.index; | ||||
| 52 | + chunks.push(body.trim().replace(/;?$/, ";")); | ||||
| 53 | + } | ||||
| 54 | + if (!chunks.length) continue; | ||||
| 55 | + const js = chunks.join("\n") + "\n"; | ||||
| 56 | + const hash = createHash("sha256").update(js).digest("hex").slice(0, 16); | ||||
| 57 | + const name = `${hash}.js`; | ||||
| 58 | + writeFileSync(join(flightDir, name), js); | ||||
| 59 | + let i = 0; | ||||
| 60 | + const rewritten = html.replace(INLINE, () => (i++ === 0 ? `<script src="/_next/static/flight/${name}"></script>` : "")); | ||||
| 61 | + writeFileSync(file, rewritten); | ||||
| 62 | + files++; | ||||
| 63 | + scripts += chunks.length; | ||||
| 64 | + bytes += js.length; | ||||
| 65 | +} | ||||
| 66 | +console.log(`externalize-inline-scripts: ${files} html files, ${scripts} inline scripts moved to _next/static/flight (${(bytes / 1024).toFixed(1)} kB)`); | ||||
added
apps/web/src/app/audit/page.tsx
+85 −0
@@ -0,0 +1,85 @@ | |||||
| 1 | +"use client"; | ||||
| 2 | +import { useState } from "react"; | ||||
| 3 | +import { RefreshCw } from "lucide-react"; | ||||
| 4 | +import { api, type AuditRow } from "@/lib/api"; | ||||
| 5 | +import { useAsync, useDebounced } from "@/lib/hooks"; | ||||
| 6 | +import { useAuth } from "@/lib/auth"; | ||||
| 7 | +import { fmtInt, fmtTs } from "@/lib/format"; | ||||
| 8 | +import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui"; | ||||
| 9 | +import { DataTable } from "@/components/DataTable"; | ||||
| 10 | +import { JsonView } from "@/components/JsonView"; | ||||
| 11 | + | ||||
| 12 | +const ACTIONS = ["", "auth.", "auth.login", "auth.mfa", "admin.", "admin.enqueue", "admin.key_create", "admin.key_revoke", "export.", "query.sql"]; | ||||
| 13 | +const HIDDEN = new Set(["id", "at", "action", "actor", "principal", "target", "outcome", "ip", "request_id", "detail", "user_agent"]); | ||||
| 14 | + | ||||
| 15 | +export default function AuditPage() { | ||||
| 16 | + const { isOwner } = useAuth(); | ||||
| 17 | + const [action, setAction] = useState(""); | ||||
| 18 | + const [limit, setLimit] = useState(200); | ||||
| 19 | + const da = useDebounced(action.trim(), 300); | ||||
| 20 | + const { data, error, loading, reload } = useAsync((s) => api.get<{ data: AuditRow[] }>("/admin/audit", { action: da || undefined, limit }, s), [da, limit], { enabled: isOwner }); | ||||
| 21 | + | ||||
| 22 | + if (!isOwner) return <Banner tone="warn">The audit log is restricted to the owner.</Banner>; | ||||
| 23 | + | ||||
| 24 | + return ( | ||||
| 25 | + <> | ||||
| 26 | + <PageHeader | ||||
| 27 | + title="Audit log" | ||||
| 28 | + subtitle="Every authentication event, admin action, export and SQL query, with request ids." | ||||
| 29 | + actions={ | ||||
| 30 | + <> | ||||
| 31 | + <Field label="Action prefix" className="w-48"> | ||||
| 32 | + <input className="input py-1 font-mono text-xs" list="audit-actions" value={action} onChange={(e) => setAction(e.target.value)} placeholder="admin." /> | ||||
| 33 | + <datalist id="audit-actions"> | ||||
| 34 | + {ACTIONS.filter(Boolean).map((a) => ( | ||||
| 35 | + <option key={a} value={a} /> | ||||
| 36 | + ))} | ||||
| 37 | + </datalist> | ||||
| 38 | + </Field> | ||||
| 39 | + <Field label="Limit" className="w-24"> | ||||
| 40 | + <select className="input py-1 text-xs" value={limit} onChange={(e) => setLimit(Number(e.target.value))}> | ||||
| 41 | + {[100, 200, 500, 1000, 2000].map((n) => ( | ||||
| 42 | + <option key={n} value={n}> | ||||
| 43 | + {n} | ||||
| 44 | + </option> | ||||
| 45 | + ))} | ||||
| 46 | + </select> | ||||
| 47 | + </Field> | ||||
| 48 | + <button className="btn btn-sm self-end" onClick={reload}> | ||||
| 49 | + <RefreshCw className="h-3.5 w-3.5" /> Refresh | ||||
| 50 | + </button> | ||||
| 51 | + </> | ||||
| 52 | + } | ||||
| 53 | + /> | ||||
| 54 | + {error && <ErrorState error={error} retry={reload} />} | ||||
| 55 | + <Card> | ||||
| 56 | + <DataTable | ||||
| 57 | + rows={data?.data} | ||||
| 58 | + loading={loading} | ||||
| 59 | + rowKey={(r, i) => String(r.id ?? `${r.at}-${i}`)} | ||||
| 60 | + mobile="cards" | ||||
| 61 | + maxHeight="75dvh" | ||||
| 62 | + columns={[ | ||||
| 63 | + { key: "at", header: "At (UTC)", render: (r) => <span className="tnum text-xs whitespace-nowrap">{fmtTs(r.at)}</span> }, | ||||
| 64 | + { key: "action", header: "Action", mono: true }, | ||||
| 65 | + { key: "actor", header: "Actor", render: (r) => <span className="text-xs">{String(r.actor ?? r.principal ?? "—")}</span> }, | ||||
| 66 | + { key: "target", header: "Target", render: (r) => <span className="font-mono text-xs break-all">{r.target ?? "—"}</span> }, | ||||
| 67 | + { key: "outcome", header: "Outcome", render: (r) => (r.outcome ? <StatusBadge status={r.outcome === "denied" ? "denied" : r.outcome === "ok" ? "ok" : r.outcome} label={r.outcome} /> : <span className="text-ink-3">—</span>) }, | ||||
| 68 | + { key: "ip", header: "IP", hideSm: true, mono: true }, | ||||
| 69 | + { | ||||
| 70 | + key: "detail", | ||||
| 71 | + header: "Detail", | ||||
| 72 | + render: (r) => { | ||||
| 73 | + const d = r.detail ?? Object.fromEntries(Object.entries(r).filter(([k]) => !HIDDEN.has(k))); | ||||
| 74 | + return Object.keys(d).length ? <JsonView value={d} collapsed /> : <span className="text-ink-3">—</span>; | ||||
| 75 | + }, | ||||
| 76 | + }, | ||||
| 77 | + { key: "request_id", header: "Request", hideSm: true, render: (r) => <span className="font-mono text-[11px] text-ink-3">{r.request_id ?? "—"}</span> }, | ||||
| 78 | + ]} | ||||
| 79 | + empty="No audit entries match." | ||||
| 80 | + footer={<span>{fmtInt(data?.data.length ?? 0)} entries</span>} | ||||
| 81 | + /> | ||||
| 82 | + </Card> | ||||
| 83 | + </> | ||||
| 84 | + ); | ||||
| 85 | +} | ||||
modified
apps/web/src/app/bars/page.tsx
+2 −2
@@ -24,7 +24,7 @@ export default function BarsPage() { | |||||
| 24 | const [out, setOut] = useState<BarsOut | null>(null); | 24 | const [out, setOut] = useState<BarsOut | null>(null); | ||
| 25 | const [rows, setRows] = useState<Bar[]>([]); | 25 | const [rows, setRows] = useState<Bar[]>([]); | ||
| 26 | const [busy, setBusy] = useState(false); | 26 | const [busy, setBusy] = useState(false); | ||
| 27 | − const [error, setError] = useState<unknown>(null); | 27 | + const [error, setError] = useState<Error | null>(null); | ||
| 28 | const [view, setView] = useState<"chart" | "table">("chart"); | 28 | const [view, setView] = useState<"chart" | "table">("chart"); | ||
| 29 | const toast = useToast(); | 29 | const toast = useToast(); | ||
| 30 | 30 | ||||
@@ -47,7 +47,7 @@ export default function BarsPage() { | |||||
| 47 | setOut(o); | 47 | setOut(o); | ||
| 48 | setRows(o.data); | 48 | setRows(o.data); | ||
| 49 | } catch (err) { | 49 | } catch (err) { | ||
| 50 | − setError(err); | 50 | + setError(err instanceof Error ? err : new Error(String(err))); | ||
| 51 | setOut(null); | 51 | setOut(null); | ||
| 52 | setRows([]); | 52 | setRows([]); | ||
| 53 | } finally { | 53 | } finally { | ||
added
apps/web/src/app/docs/page.tsx
+239 −0
@@ -0,0 +1,239 @@ | |||||
| 1 | +"use client"; | ||||
| 2 | +import Link from "next/link"; | ||||
| 3 | +import { ExternalLink } from "lucide-react"; | ||||
| 4 | +import { Banner, Card, PageHeader } from "@/components/ui"; | ||||
| 5 | + | ||||
| 6 | +const BAR_COLUMNS: [string, string, string][] = [ | ||||
| 7 | + ["instrument_id", "string", "Stable id, e.g. firstrate:stock:AAPL"], | ||||
| 8 | + ["symbol_as_reported", "string", "Ticker as delivered by the vendor at that date"], | ||||
| 9 | + ["venue", "string?", "Exchange / venue when known"], | ||||
| 10 | + ["asset_class", "string", "stock, etf, index, crypto, fx, futures"], | ||||
| 11 | + ["interval", "string", "1m, 5m, 1d"], | ||||
| 12 | + ["timestamp_utc", "timestamp[us, UTC]", "Bar OPEN instant, UTC"], | ||||
| 13 | + ["session_date", "date", "US trading session (America/New_York)"], | ||||
| 14 | + ["open / high / low / close", "float64", "Raw (as traded) prices"], | ||||
| 15 | + ["volume", "float64", "Shares/contracts; zero-volume bars are absent"], | ||||
| 16 | + ["currency", "string", "ISO 4217 (USD)"], | ||||
| 17 | + ["trade_count", "int32?", "When supplied by the vendor"], | ||||
| 18 | + ["vwap", "float64?", "When supplied by the vendor"], | ||||
| 19 | + ["adjustment_type", "string", "Always raw in storage"], | ||||
| 20 | + ["source_id", "string", "Source / product that produced the row"], | ||||
| 21 | + ["ingested_at", "timestamp[us, UTC]", "Load instant"], | ||||
| 22 | + ["quality_flags", "list<string>", "e.g. ohlc_inconsistent, price_jump, outside_session"], | ||||
| 23 | + ["revision_id", "int32", "Increments when the vendor re-delivers a bar"], | ||||
| 24 | +]; | ||||
| 25 | + | ||||
| 26 | +export default function DocsPage() { | ||||
| 27 | + return ( | ||||
| 28 | + <> | ||||
| 29 | + <PageHeader | ||||
| 30 | + title="Documentation" | ||||
| 31 | + subtitle="What FinData OS is, the data contract, licensing and how to use the API." | ||||
| 32 | + actions={ | ||||
| 33 | + <> | ||||
| 34 | + <a className="btn btn-sm" href="/api/docs" target="_blank" rel="noopener"> | ||||
| 35 | + Swagger UI <ExternalLink className="h-3.5 w-3.5" /> | ||||
| 36 | + </a> | ||||
| 37 | + <a className="btn btn-sm" href="/api/v1/openapi.json" target="_blank" rel="noopener"> | ||||
| 38 | + OpenAPI JSON <ExternalLink className="h-3.5 w-3.5" /> | ||||
| 39 | + </a> | ||||
| 40 | + </> | ||||
| 41 | + } | ||||
| 42 | + /> | ||||
| 43 | + <div className="prose-sm grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]"> | ||||
| 44 | + <nav className="card hidden h-fit lg:sticky lg:top-6 lg:block" aria-label="On this page"> | ||||
| 45 | + <ul className="flex flex-col gap-1 text-sm"> | ||||
| 46 | + {[["#platform", "The platform"], ["#contract", "Data contract"], ["#bars", "Bars columns"], ["#adjustments", "Adjustments"], ["#fundamentals", "Fundamentals (PIT)"], ["#license", "License"], ["#api", "API quick start"], ["#sdk", "Python SDK"], ["#limits", "Limits"], ["#limitations", "Limitations"]].map(([h, l]) => ( | ||||
| 47 | + <li key={h}> | ||||
| 48 | + <a href={h} className="nav-link"> | ||||
| 49 | + {l} | ||||
| 50 | + </a> | ||||
| 51 | + </li> | ||||
| 52 | + ))} | ||||
| 53 | + </ul> | ||||
| 54 | + </nav> | ||||
| 55 | + <div className="flex min-w-0 flex-col gap-4"> | ||||
| 56 | + <Card title="The platform" id="platform"> | ||||
| 57 | + <p className="text-sm"> | ||||
| 58 | + <strong>FinData OS</strong> is a private, research-grade financial data lakehouse. It ingests vendor files (FirstRate Data intraday bars, corporate actions and fundamentals; SEC EDGAR when configured) into an immutable Parquet lake with a | ||||
| 59 | + Postgres catalog, versions every dataset, checks quality at load time, and serves the result through one HTTP API that this console, the Python SDK and ad-hoc SQL all share. One writer (the ingest worker) owns the lake; the API is read-only | ||||
| 60 | + except for job control, keys and settings. | ||||
| 61 | + </p> | ||||
| 62 | + <ul className="mt-2 list-disc pl-5 text-sm text-ink-2"> | ||||
| 63 | + <li>Curated layer: Parquet files per dataset, <code>base</code> files plus <code>delta</code> appends, compacted into new versions.</li> | ||||
| 64 | + <li>Raw layer: vendor archives kept for re-processing (hash-verified).</li> | ||||
| 65 | + <li>Catalog: datasets, versions, files, coverage per (symbol, session), instruments, corporate actions, jobs, exports, audit.</li> | ||||
| 66 | + </ul> | ||||
| 67 | + </Card> | ||||
| 68 | + | ||||
| 69 | + <Card title="Data contract" id="contract"> | ||||
| 70 | + <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[auto_1fr]"> | ||||
| 71 | + <dt className="font-medium">Timestamp convention</dt> | ||||
| 72 | + <dd> | ||||
| 73 | + Every bar is stamped at its <strong>open</strong>: the 1-minute bar labelled 13:30:00Z covers 13:30:00 → 13:30:59.999 UTC. | ||||
| 74 | + </dd> | ||||
| 75 | + <dt className="font-medium">Storage timezone</dt> | ||||
| 76 | + <dd> | ||||
| 77 | + <code>timestamp_utc</code> is UTC. <code>session_date</code> is the US trading session in America/New_York; it is the key for coverage and expected-bar counts. | ||||
| 78 | + </dd> | ||||
| 79 | + <dt className="font-medium">Session span</dt> | ||||
| 80 | + <dd> | ||||
| 81 | + Bars are retained for the extended session <strong>04:00 – 20:00 ET</strong>; regular hours are 09:30 – 16:00 ET. Completeness = bars ÷ expected regular-hours bars, capped at 100 %. | ||||
| 82 | + </dd> | ||||
| 83 | + <dt className="font-medium">Zero-volume bars</dt> | ||||
| 84 | + <dd>Minutes without a trade are <strong>absent</strong>, not stored as zero rows. A gap is not necessarily a vendor outage — check the coverage page.</dd> | ||||
| 85 | + <dt className="font-medium">Adjustments</dt> | ||||
| 86 | + <dd> | ||||
| 87 | + Stored prices are <strong>raw</strong>. <code>split</code> and <code>split_div</code> are computed at query time from the corporate-actions table (ex-date based, backward factor), so a new split re-adjusts history without rewriting | ||||
| 88 | + files. The API echoes <code>adjustment_notes</code> explaining what was applied. | ||||
| 89 | + </dd> | ||||
| 90 | + <dt className="font-medium">Versioning</dt> | ||||
| 91 | + <dd> | ||||
| 92 | + Each dataset has a <code>current_version</code> with a snapshot id. Exports pin the version at submission; <code>GET /market/bars?version=</code> can read an earlier one. | ||||
| 93 | + </dd> | ||||
| 94 | + <dt className="font-medium">Quality flags</dt> | ||||
| 95 | + <dd>Rows are never silently dropped; suspicious bars carry <code>quality_flags</code> and are also recorded as anomalies.</dd> | ||||
| 96 | + </dl> | ||||
| 97 | + </Card> | ||||
| 98 | + | ||||
| 99 | + <Card title="Bars schema (findata.schema = bars, version 1)" id="bars"> | ||||
| 100 | + <div className="table-wrap"> | ||||
| 101 | + <table className="table"> | ||||
| 102 | + <thead> | ||||
| 103 | + <tr> | ||||
| 104 | + <th>Column</th> | ||||
| 105 | + <th>Arrow type</th> | ||||
| 106 | + <th>Meaning</th> | ||||
| 107 | + </tr> | ||||
| 108 | + </thead> | ||||
| 109 | + <tbody> | ||||
| 110 | + {BAR_COLUMNS.map(([c, t, m]) => ( | ||||
| 111 | + <tr key={c}> | ||||
| 112 | + <td className="font-mono text-xs">{c}</td> | ||||
| 113 | + <td className="font-mono text-xs text-ink-2">{t}</td> | ||||
| 114 | + <td className="text-xs">{m}</td> | ||||
| 115 | + </tr> | ||||
| 116 | + ))} | ||||
| 117 | + </tbody> | ||||
| 118 | + </table> | ||||
| 119 | + </div> | ||||
| 120 | + <p className="mt-2 text-xs text-ink-3"> | ||||
| 121 | + The authoritative schema is served by <code>GET /api/v1/catalog/schema/bars</code>. In the SQL playground each dataset is a DuckDB view named after its id with dots replaced by underscores, e.g. <code>market_bars_stock_1m_raw</code>. | ||||
| 122 | + </p> | ||||
| 123 | + </Card> | ||||
| 124 | + | ||||
| 125 | + <Card title="Adjustments in practice" id="adjustments"> | ||||
| 126 | + <ul className="list-disc pl-5 text-sm"> | ||||
| 127 | + <li> | ||||
| 128 | + <code>raw</code> — prices as traded; volumes as reported. Use for microstructure work and when you join your own actions. | ||||
| 129 | + </li> | ||||
| 130 | + <li> | ||||
| 131 | + <code>split</code> — prices divided and volumes multiplied by the cumulative split factor of all splits with ex-date after the bar. | ||||
| 132 | + </li> | ||||
| 133 | + <li> | ||||
| 134 | + <code>split_div</code> — additionally applies the cash-dividend backward factor <em>(1 − D / P<sub>close, prev</sub>)</em>. Total-return style; not suitable for tick-size studies. | ||||
| 135 | + </li> | ||||
| 136 | + </ul> | ||||
| 137 | + </Card> | ||||
| 138 | + | ||||
| 139 | + <Card title="Fundamentals — point in time" id="fundamentals"> | ||||
| 140 | + <p className="text-sm"> | ||||
| 141 | + Every fact carries <code>available_at</code> (= filing date + 1 day 00:00 America/New_York, a conservative knowability instant) and <code>observed_at</code> (when this platform first saw the value). A request with <code>as_of</code> returns | ||||
| 142 | + only facts with <code>available_at ≤ as_of</code>; among revisions, the first observed value is used unless a later revision was observed before <code>as_of</code>. The response counts <code>facts_hidden_by_as_of</code> so you can tell how much of the | ||||
| 143 | + record you are not seeing. | ||||
| 144 | + </p> | ||||
| 145 | + </Card> | ||||
| 146 | + | ||||
| 147 | + <Card title="License and redistribution" id="license"> | ||||
| 148 | + <Banner tone="warn"> | ||||
| 149 | + <strong>FirstRate Data — private use only, no redistribution.</strong> | ||||
| 150 | + </Banner> | ||||
| 151 | + <ul className="mt-3 list-disc pl-5 text-sm"> | ||||
| 152 | + <li>Raw bars, exports, SQL results and tables derived row-for-row from them may be used by the subscriber only. They may <strong>not</strong> be published, shared, re-hosted or served through a public API.</li> | ||||
| 153 | + <li>Aggregated results (charts, statistics, model outputs) may be published with attribution to FirstRate Data.</li> | ||||
| 154 | + <li>SEC EDGAR content is U.S. public domain; the SEC fair-access policy (declared User-Agent, request rate) applies to retrieval.</li> | ||||
| 155 | + <li>API keys are personal. Every download, export and query is written to the audit log with the principal and request id.</li> | ||||
| 156 | + </ul> | ||||
| 157 | + </Card> | ||||
| 158 | + | ||||
| 159 | + <Card title="API quick start" id="api"> | ||||
| 160 | + <p className="text-sm">All endpoints live under <code>/api/v1</code>. Authenticate with the console session (cookie + <code>X-CSRF-Token</code> on writes) or with an API key: <code>Authorization: Bearer fd_…</code>. Errors are JSON: <code>{`{"error":{"code","message","request_id"}}`}</code>.</p> | ||||
| 161 | + <pre className="mt-2 overflow-auto rounded-md border border-hairline bg-surface-2 p-3 text-xs leading-relaxed">{`export FINDATA_URL="https://findata.spboucher.ai" | ||||
| 162 | +export FINDATA_API_KEY="fd_xxx.yyy" | ||||
| 163 | + | ||||
| 164 | +# health + catalog | ||||
| 165 | +curl -s -H "Authorization: Bearer $FINDATA_API_KEY" "$FINDATA_URL/api/v1/health" | jq .status | ||||
| 166 | +curl -s -H "Authorization: Bearer $FINDATA_API_KEY" "$FINDATA_URL/api/v1/catalog/datasets" | jq '.data[].id' | ||||
| 167 | + | ||||
| 168 | +# one week of 1-minute bars, split-adjusted, as CSV (≤ 31 days per call for 1m) | ||||
| 169 | +curl -s -H "Authorization: Bearer $FINDATA_API_KEY" \\ | ||||
| 170 | + "$FINDATA_URL/api/v1/market/bars?symbol=AAPL&interval=1m&start=2026-10-20&end=2026-10-24&adjustment=split&format=csv&limit=10000" -o aapl.csv | ||||
| 171 | + | ||||
| 172 | +# bigger ranges: async export → poll → download | ||||
| 173 | +curl -s -X POST -H "Authorization: Bearer $FINDATA_API_KEY" -H "Content-Type: application/json" \\ | ||||
| 174 | + -d '{"dataset_id":"market_bars.stock.1m.raw","symbols":["AAPL","MSFT"],"start":"2024-01-01","end":"2026-10-30","format":"parquet"}' \\ | ||||
| 175 | + "$FINDATA_URL/api/v1/exports" | ||||
| 176 | +# → {"id":"…","status":"queued","poll":"/api/v1/exports/…"}; when status=ready: GET /api/v1/exports/{id}/download | ||||
| 177 | + | ||||
| 178 | +# point-in-time fundamentals | ||||
| 179 | +curl -s -H "Authorization: Bearer $FINDATA_API_KEY" \\ | ||||
| 180 | + "$FINDATA_URL/api/v1/fundamentals/AAPL?as_of=2025-01-01T00:00:00Z&metrics=rev,net_inc&period_type=q" | ||||
| 181 | + | ||||
| 182 | +# read-only SQL (DuckDB) | ||||
| 183 | +curl -s -X POST -H "Authorization: Bearer $FINDATA_API_KEY" -H "Content-Type: application/json" \\ | ||||
| 184 | + -d '{"sql":"SELECT session_date, sum(volume) v FROM market_bars_stock_1m_raw WHERE symbol_as_reported='"'"'AAPL'"'"' GROUP BY 1 ORDER BY 1 DESC LIMIT 5","max_rows":100}' \\ | ||||
| 185 | + "$FINDATA_URL/api/v1/query"`}</pre> | ||||
| 186 | + </Card> | ||||
| 187 | + | ||||
| 188 | + <Card title="Python SDK" id="sdk"> | ||||
| 189 | + <p className="text-sm"> | ||||
| 190 | + Install from the repo (<code>packages/python-sdk</code>): <code>pip install -e packages/python-sdk</code>. The client pages through Arrow IPC automatically. | ||||
| 191 | + </p> | ||||
| 192 | + <pre className="mt-2 overflow-auto rounded-md border border-hairline bg-surface-2 p-3 text-xs leading-relaxed">{`from findata_sdk import FinData | ||||
| 193 | + | ||||
| 194 | +fd = FinData("https://findata.spboucher.ai", api_key="fd_xxx.yyy") # or FINDATA_URL / FINDATA_API_KEY env vars | ||||
| 195 | + | ||||
| 196 | +bars = fd.bars("AAPL", "1m", "2026-10-26", "2026-10-30", adjustment="split") # pyarrow.Table, all pages | ||||
| 197 | +df = bars.to_pandas() # or .to_polars() | ||||
| 198 | + | ||||
| 199 | +path = fd.export(["AAPL", "MSFT"], "2024-01-01", "2026-10-30", format="parquet") # waits, downloads, returns local path | ||||
| 200 | + | ||||
| 201 | +facts = fd.fundamentals("AAPL", as_of="2025-01-01T00:00:00Z", metrics=["rev", "net_inc"]) | ||||
| 202 | +print(fd.datasets()[0]["id"], fd.health()["status"])`}</pre> | ||||
| 203 | + </Card> | ||||
| 204 | + | ||||
| 205 | + <Card title="Limits" id="limits"> | ||||
| 206 | + <ul className="list-disc pl-5 text-sm"> | ||||
| 207 | + <li> | ||||
| 208 | + <code>GET /market/bars</code>: span ≤ <strong>31 days</strong> for 1m, 186 days for 5m, 3 660 days for 1d; ≤ 10 000 rows per page (cursor pagination). HTTP 413 <code>range_too_large</code> otherwise → use{" "} | ||||
| 209 | + <Link href="/exports/" className="text-accent-ink hover:underline"> | ||||
| 210 | + Exports | ||||
| 211 | + </Link> | ||||
| 212 | + . | ||||
| 213 | + </li> | ||||
| 214 | + <li>Exports: ≤ 500 symbols, ≤ 12 years, 3 concurrent per principal, files expire after ~72 h.</li> | ||||
| 215 | + <li>SQL: one read-only statement, server-side timeout, ≤ 100 000 rows; the console displays at most 1 000 rows (download the CSV for the rest).</li> | ||||
| 216 | + <li>Rate limit per key/session (default 600 requests/min, HTTP 429 with <code>Retry-After</code>).</li> | ||||
| 217 | + </ul> | ||||
| 218 | + </Card> | ||||
| 219 | + | ||||
| 220 | + <Card title="Known limitations" id="limitations"> | ||||
| 221 | + <ul className="list-disc pl-5 text-sm"> | ||||
| 222 | + <li> | ||||
| 223 | + <strong>Fundamentals revision history starts at first ingestion.</strong> Values observed before the platform existed are a single revision (proxy for “as originally filed”); true as-filed history for older periods is not reconstructed. | ||||
| 224 | + </li> | ||||
| 225 | + <li> | ||||
| 226 | + <strong>SEC EDGAR is “pending configuration”</strong> until a fair-access User-Agent is set on the server (<code>SEC_EDGAR_USER_AGENT</code>). Until then the dataset is listed but empty. | ||||
| 227 | + </li> | ||||
| 228 | + <li> | ||||
| 229 | + <strong>Unsupported:</strong> tick data, quotes/NBBO, order-book depth, options chains. Those datasets appear as <em>unsupported</em> in the catalog on purpose so nobody plans on them. | ||||
| 230 | + </li> | ||||
| 231 | + <li>Vendor files arrive with a lag (typically next day); the overview shows <em>vendor lag</em> and <em>days since last session</em> per dataset.</li> | ||||
| 232 | + <li>The console is a static bundle served by the API; it needs JavaScript and a modern browser. No telemetry, no third-party assets.</li> | ||||
| 233 | + </ul> | ||||
| 234 | + </Card> | ||||
| 235 | + </div> | ||||
| 236 | + </div> | ||||
| 237 | + </> | ||||
| 238 | + ); | ||||
| 239 | +} | ||||
added
apps/web/src/app/fundamentals/page.tsx
+248 −0
@@ -0,0 +1,248 @@ | |||||
| 1 | +"use client"; | ||||
| 2 | +import { useEffect, useMemo, useState, type FormEvent } from "react"; | ||||
| 3 | +import { Play } from "lucide-react"; | ||||
| 4 | +import clsx from "clsx"; | ||||
| 5 | +import { api, type Fact, type FundamentalsOut, type PeriodsOut } from "@/lib/api"; | ||||
| 6 | +import { useQueryParam } from "@/lib/hooks"; | ||||
| 7 | +import { fmtCompact, fmtInt, fmtNum, fmtTs, nowLocalInput } from "@/lib/format"; | ||||
| 8 | +import { Banner, Card, Empty, ErrorState, Field, PageHeader, StatusBadge, Tabs } from "@/components/ui"; | ||||
| 9 | +import { DataTable } from "@/components/DataTable"; | ||||
| 10 | + | ||||
| 11 | +const DEFAULT_PIVOT = 6; | ||||
| 12 | + | ||||
| 13 | +export default function FundamentalsPage() { | ||||
| 14 | + const qTicker = useQueryParam("ticker"); | ||||
| 15 | + const [ticker, setTicker] = useState("AAPL"); | ||||
| 16 | + const [asOf, setAsOf] = useState(nowLocalInput()); | ||||
| 17 | + const [periodType, setPeriodType] = useState<"" | "q" | "a">("q"); | ||||
| 18 | + const [metrics, setMetrics] = useState(""); | ||||
| 19 | + const [out, setOut] = useState<FundamentalsOut | null>(null); | ||||
| 20 | + const [periods, setPeriods] = useState<PeriodsOut | null>(null); | ||||
| 21 | + const [busy, setBusy] = useState(false); | ||||
| 22 | + const [error, setError] = useState<Error | null>(null); | ||||
| 23 | + const [tab, setTab] = useState<"pivot" | "facts" | "periods" | "earnings">("pivot"); | ||||
| 24 | + const [pivotMetrics, setPivotMetrics] = useState<string[]>([]); | ||||
| 25 | + | ||||
| 26 | + useEffect(() => { | ||||
| 27 | + if (qTicker) setTicker(qTicker.toUpperCase()); | ||||
| 28 | + }, [qTicker]); | ||||
| 29 | + | ||||
| 30 | + async function run(e?: FormEvent) { | ||||
| 31 | + e?.preventDefault(); | ||||
| 32 | + setBusy(true); | ||||
| 33 | + setError(null); | ||||
| 34 | + try { | ||||
| 35 | + const asOfIso = asOf ? new Date(asOf).toISOString() : undefined; | ||||
| 36 | + const t = ticker.trim(); | ||||
| 37 | + const [f, p] = await Promise.all([ | ||||
| 38 | + api.get<FundamentalsOut>(`/fundamentals/${encodeURIComponent(t)}`, { as_of: asOfIso, metrics: metrics.trim() || undefined, period_type: periodType || undefined, limit: 5000 }), | ||||
| 39 | + api.get<PeriodsOut>(`/fundamentals/${encodeURIComponent(t)}/periods`).catch(() => null), | ||||
| 40 | + ]); | ||||
| 41 | + setOut(f); | ||||
| 42 | + setPeriods(p); | ||||
| 43 | + const all = Array.from(new Set(f.data.map((x) => x.metric))); | ||||
| 44 | + setPivotMetrics((cur) => (cur.length ? cur.filter((m) => all.includes(m)) : all.slice(0, DEFAULT_PIVOT))); | ||||
| 45 | + } catch (err) { | ||||
| 46 | + setError(err instanceof Error ? err : new Error(String(err))); | ||||
| 47 | + setOut(null); | ||||
| 48 | + setPeriods(null); | ||||
| 49 | + } finally { | ||||
| 50 | + setBusy(false); | ||||
| 51 | + } | ||||
| 52 | + } | ||||
| 53 | + | ||||
| 54 | + const allMetrics = useMemo(() => Array.from(new Set((out?.data ?? []).map((x) => x.metric))).sort(), [out]); | ||||
| 55 | + const pivot = useMemo(() => { | ||||
| 56 | + const rows = new Map<string, { period: string; type: string; fy: number | null; fp: string | null; filing: string | null; values: Record<string, Fact> }>(); | ||||
| 57 | + for (const f of out?.data ?? []) { | ||||
| 58 | + const key = `${f.fiscal_period_end}|${f.fiscal_period_type}`; | ||||
| 59 | + const r = rows.get(key) ?? { period: f.fiscal_period_end, type: f.fiscal_period_type, fy: f.fiscal_year, fp: f.fiscal_period, filing: f.filing_date, values: {} }; | ||||
| 60 | + r.values[f.metric] = f; | ||||
| 61 | + rows.set(key, r); | ||||
| 62 | + } | ||||
| 63 | + return Array.from(rows.values()).sort((a, b) => b.period.localeCompare(a.period)); | ||||
| 64 | + }, [out]); | ||||
| 65 | + | ||||
| 66 | + return ( | ||||
| 67 | + <> | ||||
| 68 | + <PageHeader title="Fundamentals" subtitle="Point-in-time facts: only what was knowable at the chosen instant is returned." /> | ||||
| 69 | + <Card className="mb-4"> | ||||
| 70 | + <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-5 md:items-end"> | ||||
| 71 | + <Field label="Ticker or entity id"> | ||||
| 72 | + <input className="input font-mono uppercase" value={ticker} onChange={(e) => setTicker(e.target.value)} required placeholder="AAPL or frd:AAPL" /> | ||||
| 73 | + </Field> | ||||
| 74 | + <Field label="As of (local time → UTC)"> | ||||
| 75 | + <input type="datetime-local" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} /> | ||||
| 76 | + </Field> | ||||
| 77 | + <Field label="Period type"> | ||||
| 78 | + <select className="input" value={periodType} onChange={(e) => setPeriodType(e.target.value as typeof periodType)}> | ||||
| 79 | + <option value="q">quarterly</option> | ||||
| 80 | + <option value="a">annual</option> | ||||
| 81 | + <option value="">both</option> | ||||
| 82 | + </select> | ||||
| 83 | + </Field> | ||||
| 84 | + <Field label="Metrics (comma-separated)"> | ||||
| 85 | + <input className="input font-mono text-xs" value={metrics} onChange={(e) => setMetrics(e.target.value)} placeholder="rev, net_inc, eps" /> | ||||
| 86 | + </Field> | ||||
| 87 | + <button className="btn btn-primary" disabled={busy || !ticker.trim()}> | ||||
| 88 | + <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"} | ||||
| 89 | + </button> | ||||
| 90 | + </form> | ||||
| 91 | + </Card> | ||||
| 92 | + | ||||
| 93 | + {error && <ErrorState error={error} retry={() => void run()} />} | ||||
| 94 | + | ||||
| 95 | + {out && ( | ||||
| 96 | + <> | ||||
| 97 | + <div className="mb-3 flex flex-wrap items-center gap-2 text-xs"> | ||||
| 98 | + <span className="pill bg-surface-2 text-ink-2 tnum">{fmtInt(out.meta.rows)} facts</span> | ||||
| 99 | + <span className="pill bg-surface-2 text-ink-2 tnum">as of {fmtTs(out.meta.as_of)}</span> | ||||
| 100 | + {out.meta.facts_hidden_by_as_of > 0 && <span className="pill bg-warn-soft text-warn-ink tnum">{fmtInt(out.meta.facts_hidden_by_as_of)} facts hidden by as_of</span>} | ||||
| 101 | + {out.entity && <span className="pill bg-surface-2 text-ink-2">{String(out.entity.name ?? out.entity.entity_id ?? "")}</span>} | ||||
| 102 | + <span className="ml-auto"> | ||||
| 103 | + <Tabs value={tab} onChange={setTab} items={[{ value: "pivot", label: "Pivot" }, { value: "facts", label: "Raw facts" }, { value: "periods", label: `Periods (${periods?.periods.length ?? 0})` }, { value: "earnings", label: `Earnings (${periods?.earnings.length ?? 0})` }]} /> | ||||
| 104 | + </span> | ||||
| 105 | + </div> | ||||
| 106 | + <Banner tone="accent" className="mb-4"> | ||||
| 107 | + <span className="text-xs">PIT policy: {out.meta.pit_policy}. Revision history exists only from the first ingestion by this platform.</span> | ||||
| 108 | + </Banner> | ||||
| 109 | + | ||||
| 110 | + {tab === "pivot" && ( | ||||
| 111 | + <Card title="Periods × metrics" actions={<span className="text-xs text-ink-3">{pivotMetrics.length} of {allMetrics.length} metrics</span>}> | ||||
| 112 | + <div className="mb-3 flex flex-wrap gap-1"> | ||||
| 113 | + {allMetrics.map((m) => { | ||||
| 114 | + const on = pivotMetrics.includes(m); | ||||
| 115 | + return ( | ||||
| 116 | + <button key={m} className={clsx("pill border font-mono", on ? "border-accent bg-accent-soft text-accent-ink" : "border-border bg-surface-1 text-ink-2 hover:bg-surface-3")} onClick={() => setPivotMetrics((cur) => (on ? cur.filter((x) => x !== m) : [...cur, m]))} aria-pressed={on}> | ||||
| 117 | + {m} | ||||
| 118 | + </button> | ||||
| 119 | + ); | ||||
| 120 | + })} | ||||
| 121 | + </div> | ||||
| 122 | + {pivot.length === 0 ? ( | ||||
| 123 | + <Empty>No facts visible at this as-of instant.</Empty> | ||||
| 124 | + ) : ( | ||||
| 125 | + <div className="table-wrap" style={{ maxHeight: "60dvh", overflowY: "auto" }}> | ||||
| 126 | + <table className="table"> | ||||
| 127 | + <thead> | ||||
| 128 | + <tr> | ||||
| 129 | + <th>Period end</th> | ||||
| 130 | + <th>FY / FP</th> | ||||
| 131 | + <th>Filed</th> | ||||
| 132 | + {pivotMetrics.map((m) => ( | ||||
| 133 | + <th key={m} className="text-right font-mono"> | ||||
| 134 | + {m} | ||||
| 135 | + </th> | ||||
| 136 | + ))} | ||||
| 137 | + </tr> | ||||
| 138 | + </thead> | ||||
| 139 | + <tbody> | ||||
| 140 | + {pivot.map((r) => ( | ||||
| 141 | + <tr key={`${r.period}-${r.type}`}> | ||||
| 142 | + <td className="tnum whitespace-nowrap"> | ||||
| 143 | + {r.period} <span className="text-ink-3">{r.type}</span> | ||||
| 144 | + </td> | ||||
| 145 | + <td className="tnum text-xs text-ink-2 whitespace-nowrap"> | ||||
| 146 | + {r.fy ?? "—"} / {r.fp ?? "—"} | ||||
| 147 | + </td> | ||||
| 148 | + <td className="tnum text-xs text-ink-2 whitespace-nowrap">{r.filing ?? "—"}</td> | ||||
| 149 | + {pivotMetrics.map((m) => { | ||||
| 150 | + const f = r.values[m]; | ||||
| 151 | + return ( | ||||
| 152 | + <td key={m} className="num whitespace-nowrap" title={f ? `${f.value} ${f.unit} · rev ${f.revision_id}${f.restatement_flag ? " · restated" : ""}` : undefined}> | ||||
| 153 | + {f ? ( | ||||
| 154 | + <> | ||||
| 155 | + {fmtValue(f)} | ||||
| 156 | + {f.revision_id > 1 && <sup className="ml-0.5 text-[9px] text-warn-ink">r{f.revision_id}</sup>} | ||||
| 157 | + </> | ||||
| 158 | + ) : ( | ||||
| 159 | + <span className="text-ink-3">—</span> | ||||
| 160 | + )} | ||||
| 161 | + </td> | ||||
| 162 | + ); | ||||
| 163 | + })} | ||||
| 164 | + </tr> | ||||
| 165 | + ))} | ||||
| 166 | + </tbody> | ||||
| 167 | + </table> | ||||
| 168 | + </div> | ||||
| 169 | + )} | ||||
| 170 | + <p className="mt-2 text-[11px] text-ink-3">Values compacted (K/M/B); hover for exact value and unit. <sup>rN</sup> marks a revision observed before as-of.</p> | ||||
| 171 | + </Card> | ||||
| 172 | + )} | ||||
| 173 | + | ||||
| 174 | + {tab === "facts" && ( | ||||
| 175 | + <Card title="Raw facts"> | ||||
| 176 | + <DataTable | ||||
| 177 | + rows={out.data} | ||||
| 178 | + rowKey={(f, i) => `${f.metric}-${f.fiscal_period_end}-${f.fiscal_period_type}-${f.revision_id}-${i}`} | ||||
| 179 | + mobile="scroll" | ||||
| 180 | + maxHeight="60dvh" | ||||
| 181 | + columns={[ | ||||
| 182 | + { key: "metric", header: "Metric", mono: true }, | ||||
| 183 | + { key: "value", header: "Value", num: true, render: (f) => <span title={String(f.value)}>{fmtValue(f)}</span> }, | ||||
| 184 | + { key: "unit", header: "Unit", hideSm: true }, | ||||
| 185 | + { key: "fiscal_period_end", header: "Period end", render: (f) => <span className="tnum">{f.fiscal_period_end} <span className="text-ink-3">{f.fiscal_period_type}</span></span> }, | ||||
| 186 | + { key: "fy", header: "FY/FP", hideSm: true, render: (f) => `${f.fiscal_year ?? "—"}/${f.fiscal_period ?? "—"}` }, | ||||
| 187 | + { key: "filing_date", header: "Filed", render: (f) => <span className="tnum">{f.filing_date ?? "—"}</span> }, | ||||
| 188 | + { key: "available_at", header: "Available at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.available_at)}</span> }, | ||||
| 189 | + { key: "observed_at", header: "Observed at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.observed_at)}</span> }, | ||||
| 190 | + { key: "revision_id", header: "Rev", num: true }, | ||||
| 191 | + { key: "restatement_flag", header: "Restated", render: (f) => (f.restatement_flag ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) }, | ||||
| 192 | + { key: "source_document", header: "Source", hideSm: true, render: (f) => <span className="text-xs text-ink-2">{f.source_document ?? f.source_id}</span> }, | ||||
| 193 | + ]} | ||||
| 194 | + /> | ||||
| 195 | + </Card> | ||||
| 196 | + )} | ||||
| 197 | + | ||||
| 198 | + {tab === "periods" && ( | ||||
| 199 | + <Card title="Fiscal periods (all revisions, not PIT-filtered)"> | ||||
| 200 | + <DataTable | ||||
| 201 | + rows={periods?.periods} | ||||
| 202 | + rowKey={(p) => `${p.fiscal_period_end}-${p.fiscal_period_type}-${p.filing_date}`} | ||||
| 203 | + mobile="scroll" | ||||
| 204 | + maxHeight="60dvh" | ||||
| 205 | + columns={[ | ||||
| 206 | + { key: "fiscal_period_end", header: "Period end", render: (p) => <span className="tnum">{p.fiscal_period_end} <span className="text-ink-3">{p.fiscal_period_type}</span></span> }, | ||||
| 207 | + { key: "fy", header: "FY/FP", render: (p) => `${p.fiscal_year ?? "—"}/${p.fiscal_period ?? "—"}` }, | ||||
| 208 | + { key: "filing_date", header: "Filed", render: (p) => <span className="tnum">{p.filing_date ?? "—"}</span> }, | ||||
| 209 | + { key: "available_at", header: "Available at", render: (p) => <span className="tnum text-xs">{fmtTs(p.available_at)}</span> }, | ||||
| 210 | + { key: "metrics", header: "Metrics", num: true, render: (p) => fmtInt(p.metrics) }, | ||||
| 211 | + { key: "max_revision", header: "Max rev", num: true }, | ||||
| 212 | + { key: "restated", header: "Restated", render: (p) => (p.restated ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) }, | ||||
| 213 | + ]} | ||||
| 214 | + empty="No periods." | ||||
| 215 | + /> | ||||
| 216 | + </Card> | ||||
| 217 | + )} | ||||
| 218 | + | ||||
| 219 | + {tab === "earnings" && ( | ||||
| 220 | + <Card title="Earnings events"> | ||||
| 221 | + <DataTable | ||||
| 222 | + rows={periods?.earnings} | ||||
| 223 | + rowKey={(e) => e.event_date} | ||||
| 224 | + mobile="scroll" | ||||
| 225 | + maxHeight="60dvh" | ||||
| 226 | + columns={[ | ||||
| 227 | + { key: "event_date", header: "Date", render: (e) => <span className="tnum">{e.event_date}</span> }, | ||||
| 228 | + { key: "release_time", header: "Release", render: (e) => e.release_time ?? "—" }, | ||||
| 229 | + { key: "eps_estimate", header: "EPS est.", num: true, render: (e) => fmtNum(e.eps_estimate, 2) }, | ||||
| 230 | + { key: "eps_reported", header: "EPS rep.", num: true, render: (e) => fmtNum(e.eps_reported, 2) }, | ||||
| 231 | + { key: "surprise", header: "Surprise", num: true, render: (e) => (e.eps_estimate !== null && e.eps_reported !== null ? fmtNum(e.eps_reported - e.eps_estimate, 2) : "—") }, | ||||
| 232 | + ]} | ||||
| 233 | + empty="No earnings events." | ||||
| 234 | + /> | ||||
| 235 | + </Card> | ||||
| 236 | + )} | ||||
| 237 | + </> | ||||
| 238 | + )} | ||||
| 239 | + </> | ||||
| 240 | + ); | ||||
| 241 | +} | ||||
| 242 | + | ||||
| 243 | +function fmtValue(f: Fact): string { | ||||
| 244 | + if (f.value === null || f.value === undefined) return "—"; | ||||
| 245 | + const u = (f.unit ?? "").toLowerCase(); | ||||
| 246 | + if (u.includes("ratio") || u.includes("per_share") || u === "pct" || u === "percent" || Math.abs(f.value) < 1000) return fmtNum(f.value, 2); | ||||
| 247 | + return fmtCompact(f.value); | ||||
| 248 | +} | ||||
modified
apps/web/src/app/globals.css
+1 −1
@@ -104,7 +104,7 @@ body { | |||||
| 104 | ::selection { background: var(--accent-soft); } | 104 | ::selection { background: var(--accent-soft); } | ||
| 105 | a { color: inherit; } | 105 | a { color: inherit; } | ||
| 106 | code, pre, kbd, .mono { font-family: var(--font-mono); } | 106 | code, pre, kbd, .mono { font-family: var(--font-mono); } | ||
| 107 | −.tnum { font-variant-numeric: tabular-nums; } | 107 | +@utility tnum { font-variant-numeric: tabular-nums; } | ||
| 108 | 108 | ||||
| 109 | /* ------------------------------------------------------------------ components */ | 109 | /* ------------------------------------------------------------------ components */ | ||
| 110 | @layer components { | 110 | @layer components { | ||
modified
apps/web/src/app/playground/page.tsx
+2 −2
@@ -16,7 +16,7 @@ export default function PlaygroundPage() { | |||||
| 16 | const [maxRows, setMaxRows] = useState(1000); | 16 | const [maxRows, setMaxRows] = useState(1000); | ||
| 17 | const [out, setOut] = useState<QueryOut | null>(null); | 17 | const [out, setOut] = useState<QueryOut | null>(null); | ||
| 18 | const [busy, setBusy] = useState(false); | 18 | const [busy, setBusy] = useState(false); | ||
| 19 | − const [error, setError] = useState<unknown>(null); | 19 | + const [error, setError] = useState<Error | null>(null); | ||
| 20 | const toast = useToast(); | 20 | const toast = useToast(); | ||
| 21 | const ta = useRef<HTMLTextAreaElement>(null); | 21 | const ta = useRef<HTMLTextAreaElement>(null); | ||
| 22 | const views = useAsync((s) => api.get<{ views: { name: string; files: number }[]; examples: string[] }>("/query/views", undefined, s), []); | 22 | const views = useAsync((s) => api.get<{ views: { name: string; files: number }[]; examples: string[] }>("/query/views", undefined, s), []); | ||
@@ -34,7 +34,7 @@ export default function PlaygroundPage() { | |||||
| 34 | const o = await api.post<QueryOut>("/query", { sql, max_rows: maxRows }); | 34 | const o = await api.post<QueryOut>("/query", { sql, max_rows: maxRows }); | ||
| 35 | setOut(o); | 35 | setOut(o); | ||
| 36 | } catch (e) { | 36 | } catch (e) { | ||
| 37 | − setError(e); | 37 | + setError(e instanceof Error ? e : new Error(String(e))); | ||
| 38 | setOut(null); | 38 | setOut(null); | ||
| 39 | } finally { | 39 | } finally { | ||
| 40 | setBusy(false); | 40 | setBusy(false); | ||
added
apps/web/src/app/settings/page.tsx
+199 −0
@@ -0,0 +1,199 @@ | |||||
| 1 | +"use client"; | ||||
| 2 | +/* eslint-disable @next/next/no-img-element */ | ||||
| 3 | +import { useEffect, useState, type FormEvent } from "react"; | ||||
| 4 | +import { QrCode, Radar, Save, ShieldCheck } from "lucide-react"; | ||||
| 5 | +import { api, type SettingsOut } from "@/lib/api"; | ||||
| 6 | +import { useAsync } from "@/lib/hooks"; | ||||
| 7 | +import { useAuth } from "@/lib/auth"; | ||||
| 8 | +import { useToast } from "@/lib/toast"; | ||||
| 9 | +import { fmtInt, fmtTs, splitList } from "@/lib/format"; | ||||
| 10 | +import { Banner, Card, Empty, ErrorState, Field, KV, PageHeader, StatusBadge } from "@/components/ui"; | ||||
| 11 | +import { JsonView } from "@/components/JsonView"; | ||||
| 12 | +import { CopyButton } from "@/components/CopyButton"; | ||||
| 13 | + | ||||
| 14 | +export default function SettingsPage() { | ||||
| 15 | + const { me, isOwner, refresh } = useAuth(); | ||||
| 16 | + const toast = useToast(); | ||||
| 17 | + const settings = useAsync((s) => api.get<SettingsOut>("/admin/settings", undefined, s), [], { enabled: isOwner }); | ||||
| 18 | + | ||||
| 19 | + // TOTP | ||||
| 20 | + const [setup, setSetup] = useState<{ otpauth_uri: string; qr_png_data_url: string; secret: string } | null>(null); | ||||
| 21 | + const [code, setCode] = useState(""); | ||||
| 22 | + const [busy, setBusy] = useState(false); | ||||
| 23 | + | ||||
| 24 | + async function startSetup() { | ||||
| 25 | + setBusy(true); | ||||
| 26 | + try { | ||||
| 27 | + setSetup(await api.post("/auth/totp/setup")); | ||||
| 28 | + } catch (e) { | ||||
| 29 | + toast.error(e); | ||||
| 30 | + } finally { | ||||
| 31 | + setBusy(false); | ||||
| 32 | + } | ||||
| 33 | + } | ||||
| 34 | + async function verify(e: FormEvent) { | ||||
| 35 | + e.preventDefault(); | ||||
| 36 | + setBusy(true); | ||||
| 37 | + try { | ||||
| 38 | + await api.post("/auth/totp/verify", { code: code.replace(/\s+/g, "") }); | ||||
| 39 | + toast.success("Two-factor authentication enabled"); | ||||
| 40 | + setSetup(null); | ||||
| 41 | + setCode(""); | ||||
| 42 | + await refresh(); | ||||
| 43 | + } catch (err) { | ||||
| 44 | + toast.error(err); | ||||
| 45 | + } finally { | ||||
| 46 | + setBusy(false); | ||||
| 47 | + } | ||||
| 48 | + } | ||||
| 49 | + | ||||
| 50 | + // Tracked tickers | ||||
| 51 | + const [tickers, setTickers] = useState(""); | ||||
| 52 | + const [tickersDirty, setTickersDirty] = useState(false); | ||||
| 53 | + useEffect(() => { | ||||
| 54 | + const row = settings.data?.settings.find((s) => s.key === "fundamentals_tracked_tickers"); | ||||
| 55 | + if (row && !tickersDirty) setTickers(Array.isArray(row.value) ? (row.value as string[]).join("\n") : ""); | ||||
| 56 | + }, [settings.data, tickersDirty]); | ||||
| 57 | + async function saveTickers() { | ||||
| 58 | + setBusy(true); | ||||
| 59 | + try { | ||||
| 60 | + const out = await api.put<{ tickers: string[] }>("/admin/settings/tracked-tickers", { tickers: splitList(tickers) }); | ||||
| 61 | + toast.success("Tracked tickers saved", `${out.tickers.length} tickers`); | ||||
| 62 | + setTickers(out.tickers.join("\n")); | ||||
| 63 | + setTickersDirty(false); | ||||
| 64 | + settings.reload(); | ||||
| 65 | + } catch (e) { | ||||
| 66 | + toast.error(e); | ||||
| 67 | + } finally { | ||||
| 68 | + setBusy(false); | ||||
| 69 | + } | ||||
| 70 | + } | ||||
| 71 | + | ||||
| 72 | + // Discovery | ||||
| 73 | + const [disc, setDisc] = useState<unknown>(null); | ||||
| 74 | + async function discover() { | ||||
| 75 | + setBusy(true); | ||||
| 76 | + setDisc(null); | ||||
| 77 | + try { | ||||
| 78 | + setDisc(await api.post("/admin/discover")); | ||||
| 79 | + toast.success("Discovery finished"); | ||||
| 80 | + } catch (e) { | ||||
| 81 | + toast.error(e); | ||||
| 82 | + } finally { | ||||
| 83 | + setBusy(false); | ||||
| 84 | + } | ||||
| 85 | + } | ||||
| 86 | + | ||||
| 87 | + const env = settings.data?.env; | ||||
| 88 | + const otherSettings = (settings.data?.settings ?? []).filter((s) => s.key !== "fundamentals_tracked_tickers"); | ||||
| 89 | + | ||||
| 90 | + return ( | ||||
| 91 | + <> | ||||
| 92 | + <PageHeader title="Settings" subtitle="Account security, environment flags and ingestion preferences" /> | ||||
| 93 | + <div className="grid gap-4 lg:grid-cols-2"> | ||||
| 94 | + <Card title="Account"> | ||||
| 95 | + <KV | ||||
| 96 | + items={[ | ||||
| 97 | + ["E-mail", me?.email ?? "—"], | ||||
| 98 | + ["Role", <StatusBadge key="r" status="neutral" label={me?.role ?? "—"} icon={false} />], | ||||
| 99 | + ["Scopes", <div key="s" className="flex flex-wrap gap-1">{(me?.scopes ?? []).map((s) => <span key={s} className="pill bg-surface-2 text-ink-2">{s}</span>)}</div>], | ||||
| 100 | + ["Two-factor (TOTP)", me?.totp_enabled ? <StatusBadge key="t" status="ok" label="enabled" /> : <StatusBadge key="t" status="warning" label="not enabled" />], | ||||
| 101 | + ]} | ||||
| 102 | + /> | ||||
| 103 | + <div className="mt-4 border-t border-hairline pt-3"> | ||||
| 104 | + {!setup ? ( | ||||
| 105 | + <button className="btn" onClick={startSetup} disabled={busy}> | ||||
| 106 | + <ShieldCheck className="h-4 w-4" /> {me?.totp_enabled ? "Re-enrol authenticator" : "Enable two-factor authentication"} | ||||
| 107 | + </button> | ||||
| 108 | + ) : ( | ||||
| 109 | + <form onSubmit={verify} className="flex flex-col gap-3 sm:flex-row"> | ||||
| 110 | + <div className="shrink-0 rounded-md border border-border bg-white p-2 self-start"> | ||||
| 111 | + <img src={setup.qr_png_data_url} alt="TOTP enrolment QR code" width={160} height={160} /> | ||||
| 112 | + </div> | ||||
| 113 | + <div className="flex flex-1 flex-col gap-2"> | ||||
| 114 | + <p className="text-sm text-ink-2"> | ||||
| 115 | + <QrCode className="mr-1 inline h-4 w-4" /> Scan with your authenticator, or enter the secret manually: | ||||
| 116 | + </p> | ||||
| 117 | + <div className="flex items-center gap-2"> | ||||
| 118 | + <code className="break-all rounded bg-surface-2 px-2 py-1 text-xs">{setup.secret}</code> | ||||
| 119 | + <CopyButton text={setup.secret} /> | ||||
| 120 | + </div> | ||||
| 121 | + <Field label="Code from the app"> | ||||
| 122 | + <input className="input font-mono tracking-[0.3em]" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} required /> | ||||
| 123 | + </Field> | ||||
| 124 | + <div className="flex gap-2"> | ||||
| 125 | + <button className="btn btn-primary" disabled={busy || code.replace(/\s+/g, "").length < 6}> | ||||
| 126 | + Verify & enable | ||||
| 127 | + </button> | ||||
| 128 | + <button type="button" className="btn" onClick={() => setSetup(null)}> | ||||
| 129 | + Cancel | ||||
| 130 | + </button> | ||||
| 131 | + </div> | ||||
| 132 | + </div> | ||||
| 133 | + </form> | ||||
| 134 | + )} | ||||
| 135 | + </div> | ||||
| 136 | + </Card> | ||||
| 137 | + | ||||
| 138 | + <Card title="Environment"> | ||||
| 139 | + {!isOwner && <Empty>Environment flags are visible to the owner only.</Empty>} | ||||
| 140 | + {settings.error && <ErrorState error={settings.error} retry={settings.reload} />} | ||||
| 141 | + {env && ( | ||||
| 142 | + <KV | ||||
| 143 | + items={[ | ||||
| 144 | + ["Environment", <StatusBadge key="e" status={env.env === "production" ? "ok" : "warning"} label={env.env} />], | ||||
| 145 | + ["Lake path", <code key="l" className="text-xs">{env.lake}</code>], | ||||
| 146 | + ["Public URL", <a key="u" href={env.public_url} className="text-accent-ink hover:underline break-all" target="_blank" rel="noopener">{env.public_url}</a>], | ||||
| 147 | + ["Backup target", <code key="b" className="text-xs">{env.backup_target}</code>], | ||||
| 148 | + ["FirstRate Data vendor", <StatusBadge key="v" status={env.vendor_configured ? "ok" : "error"} label={env.vendor_configured ? "configured" : "not configured"} />], | ||||
| 149 | + ["SEC EDGAR", <StatusBadge key="s" status={env.sec_edgar_configured ? "ok" : "pending_configuration"} label={env.sec_edgar_configured ? "configured" : "pending configuration"} />], | ||||
| 150 | + ["AI providers", <span key="a" className="flex gap-1"><StatusBadge status={env.ai_providers.anthropic ? "ok" : "neutral"} label={`anthropic ${env.ai_providers.anthropic ? "yes" : "no"}`} icon={false} /><StatusBadge status={env.ai_providers.openai ? "ok" : "neutral"} label={`openai ${env.ai_providers.openai ? "yes" : "no"}`} icon={false} /></span>], | ||||
| 151 | + ]} | ||||
| 152 | + /> | ||||
| 153 | + )} | ||||
| 154 | + <p className="mt-3 text-[11px] text-ink-3">Secrets are never sent to the browser; only configured yes/no flags are shown.</p> | ||||
| 155 | + </Card> | ||||
| 156 | + | ||||
| 157 | + {isOwner && ( | ||||
| 158 | + <Card title="Tracked tickers (fundamentals)" actions={<span className="text-xs text-ink-3">{fmtInt(splitList(tickers).length)} tickers</span>}> | ||||
| 159 | + <textarea className="input min-h-40 font-mono uppercase" value={tickers} onChange={(e) => { setTickers(e.target.value); setTickersDirty(true); }} placeholder={"AAPL\nMSFT\nNVDA"} /> | ||||
| 160 | + <div className="mt-2 flex items-center justify-between gap-2"> | ||||
| 161 | + <span className="text-xs text-ink-3">Used by <code>fundamentals</code> jobs when no tickers are given. Max 2 000, deduplicated and upper-cased.</span> | ||||
| 162 | + <button className="btn btn-primary" onClick={saveTickers} disabled={busy || !tickersDirty}> | ||||
| 163 | + <Save className="h-4 w-4" /> Save | ||||
| 164 | + </button> | ||||
| 165 | + </div> | ||||
| 166 | + </Card> | ||||
| 167 | + )} | ||||
| 168 | + | ||||
| 169 | + {isOwner && ( | ||||
| 170 | + <Card title="Vendor discovery" actions={<button className="btn btn-sm" onClick={discover} disabled={busy}><Radar className="h-3.5 w-3.5" /> {busy ? "Probing…" : "Run discovery"}</button>}> | ||||
| 171 | + <p className="text-xs text-ink-2">Live, sanitized probe of FirstRate Data and SEC EDGAR (no bulk download). Output is redacted server-side and recorded in the audit log.</p> | ||||
| 172 | + <div className="mt-2">{disc ? <JsonView value={disc} maxHeight="28rem" /> : <Empty>No discovery run yet in this session.</Empty>}</div> | ||||
| 173 | + </Card> | ||||
| 174 | + )} | ||||
| 175 | + | ||||
| 176 | + {isOwner && otherSettings.length > 0 && ( | ||||
| 177 | + <Card title="Other settings (read-only)" className="lg:col-span-2"> | ||||
| 178 | + <ul className="flex flex-col gap-2"> | ||||
| 179 | + {otherSettings.map((s) => ( | ||||
| 180 | + <li key={s.key} className="text-sm"> | ||||
| 181 | + <div className="flex items-center justify-between gap-2"> | ||||
| 182 | + <code className="text-xs">{s.key}</code> | ||||
| 183 | + <span className="tnum text-xs text-ink-3">{fmtTs(s.updated_at)}</span> | ||||
| 184 | + </div> | ||||
| 185 | + <JsonView value={s.value} collapsed /> | ||||
| 186 | + </li> | ||||
| 187 | + ))} | ||||
| 188 | + </ul> | ||||
| 189 | + </Card> | ||||
| 190 | + )} | ||||
| 191 | + </div> | ||||
| 192 | + {!me?.totp_enabled && ( | ||||
| 193 | + <Banner tone="warn" className="mt-4"> | ||||
| 194 | + Two-factor authentication is not enabled on this account. Enable it above — the owner role controls jobs, keys and storage. | ||||
| 195 | + </Banner> | ||||
| 196 | + )} | ||||
| 197 | + </> | ||||
| 198 | + ); | ||||
| 199 | +} | ||||
modified
apps/web/src/components/charts.tsx
+1 −1
@@ -22,7 +22,7 @@ function TipBox({ title, rows }: { title: string; rows: { label: string; value: | |||||
| 22 | ); | 22 | ); | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | −type TipPayload = { payload?: Record<string, unknown>; dataKey?: string | number; value?: unknown }[]; | 25 | +type TipPayload = ReadonlyArray<{ payload?: unknown; value?: unknown }>; | ||
| 26 | 26 | ||||
| 27 | // ---------------------------------------------------------------- price (close) line | 27 | // ---------------------------------------------------------------- price (close) line | ||
| 28 | export function CloseLineChart({ data, height = 240 }: { data: { t: string; close: number; label: string }[]; height?: number }) { | 28 | export function CloseLineChart({ data, height = 240 }: { data: { t: string; close: number; label: string }[]; height?: number }) { | ||
modified
apps/web/src/lib/hooks.ts
+2 −2
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; | |||||
| 5 | export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unknown[], opts: { enabled?: boolean } = {}) { | 5 | export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unknown[], opts: { enabled?: boolean } = {}) { | ||
| 6 | const enabled = opts.enabled ?? true; | 6 | const enabled = opts.enabled ?? true; | ||
| 7 | const [data, setData] = useState<T | null>(null); | 7 | const [data, setData] = useState<T | null>(null); | ||
| 8 | − const [error, setError] = useState<unknown>(null); | 8 | + const [error, setError] = useState<Error | null>(null); | ||
| 9 | const [loading, setLoading] = useState<boolean>(enabled); | 9 | const [loading, setLoading] = useState<boolean>(enabled); | ||
| 10 | const [tick, setTick] = useState(0); | 10 | const [tick, setTick] = useState(0); | ||
| 11 | const fnRef = useRef(fn); | 11 | const fnRef = useRef(fn); | ||
@@ -28,7 +28,7 @@ export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unkno | |||||
| 28 | }) | 28 | }) | ||
| 29 | .catch((e) => { | 29 | .catch((e) => { | ||
| 30 | if (!alive || ctl.signal.aborted) return; | 30 | if (!alive || ctl.signal.aborted) return; | ||
| 31 | − setError(e); | 31 | + setError(e instanceof Error ? e : new Error(String(e))); | ||
| 32 | }) | 32 | }) | ||
| 33 | .finally(() => { | 33 | .finally(() => { | ||
| 34 | if (alive) setLoading(false); | 34 | if (alive) setLoading(false); | ||
modified
services/api/pyproject.toml
+1 −1
@@ -4,7 +4,7 @@ version = "0.1.0" | |||||
| 4 | requires-python = ">=3.12" | 4 | requires-python = ">=3.12" | ||
| 5 | dependencies = ["findata-core", "findata-schema", "findata-ingest", "findata-fundamentals", "findata-market-firstrate", | 5 | dependencies = ["findata-core", "findata-schema", "findata-ingest", "findata-fundamentals", "findata-market-firstrate", | ||
| 6 | "fastapi>=0.115", "email-validator>=2.2", "uvicorn[standard]>=0.32", "pydantic>=2.9", "argon2-cffi>=23.1", "pyotp>=2.9", "qrcode>=8.0", | 6 | "fastapi>=0.115", "email-validator>=2.2", "uvicorn[standard]>=0.32", "pydantic>=2.9", "argon2-cffi>=23.1", "pyotp>=2.9", "qrcode>=8.0", | ||
| 7 | − "prometheus-client>=0.21", "python-multipart>=0.0.12", "itsdangerous>=2.2", "pyarrow>=18.0", "duckdb>=1.1", "polars>=1.12"] | 7 | + "prometheus-client>=0.21", "anthropic>=1.0", "python-multipart>=0.0.12", "itsdangerous>=2.2", "pyarrow>=18.0", "duckdb>=1.1", "polars>=1.12"] | ||
| 8 | [tool.uv.sources] | 8 | [tool.uv.sources] | ||
| 9 | findata-core = { workspace = true } | 9 | findata-core = { workspace = true } | ||
| 10 | findata-schema = { workspace = true } | 10 | findata-schema = { workspace = true } | ||
modified
services/api/src/findata_api/main.py
+2 −1
@@ -19,6 +19,7 @@ from findata_api import metrics as M | |||||
| 19 | from findata_api.ratelimit import bucket | 19 | from findata_api.ratelimit import bucket | ||
| 20 | from findata_api.routers import ( | 20 | from findata_api.routers import ( | ||
| 21 | admin, | 21 | admin, | ||
| 22 | + assistant, | ||||
| 22 | auth, | 23 | auth, | ||
| 23 | catalog, | 24 | catalog, | ||
| 24 | coverage, | 25 | coverage, | ||
@@ -111,7 +112,7 @@ def create_app() -> FastAPI: | |||||
| 111 | return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500) | 112 | return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500) | ||
| 112 | 113 | ||||
| 113 | for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router, | 114 | for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router, | ||
| 114 | − coverage.router, quality.router, health.router, admin.router): | 115 | + coverage.router, quality.router, health.router, admin.router, assistant.router): | ||
| 115 | app.include_router(r, prefix=API_PREFIX) | 116 | app.include_router(r, prefix=API_PREFIX) | ||
| 116 | 117 | ||||
| 117 | @app.get("/metrics", include_in_schema=False) | 118 | @app.get("/metrics", include_in_schema=False) | ||
added
services/api/src/findata_api/routers/assistant.py
+137 −0
@@ -0,0 +1,137 @@ | |||||
| 1 | +"""Optional AI data assistant (phase 6): natural language → *validated* read-only SQL over the catalog views. | ||||
| 2 | + | ||||
| 3 | +Guarantees: the model never executes anything — it only proposes SQL; FinData validates it with the same sandbox as | ||||
| 4 | +the playground (single SELECT, allow-listed directories, timeout, row cap), shows the SQL + EXPLAIN plan, cites the | ||||
| 5 | +dataset versions used, and refuses questions about data that is not in the catalog. Server-side BYOK (Anthropic) | ||||
| 6 | +only; status is `pending_configuration` until ANTHROPIC_API_KEY is set and the capability test has been recorded. | ||||
| 7 | +""" | ||||
| 8 | +from __future__ import annotations | ||||
| 9 | + | ||||
| 10 | +import json | ||||
| 11 | +import time | ||||
| 12 | +from typing import Any | ||||
| 13 | + | ||||
| 14 | +from fastapi import APIRouter, Depends, Request | ||||
| 15 | +from findata_core.config import get_settings | ||||
| 16 | +from findata_core.db import connection | ||||
| 17 | +from findata_ingest.reader import dataset_views, run_sql | ||||
| 18 | +from pydantic import BaseModel, Field | ||||
| 19 | + | ||||
| 20 | +from findata_api import auth as A | ||||
| 21 | +from findata_api.errors import ApiError | ||||
| 22 | + | ||||
| 23 | +router = APIRouter(prefix="/assistant", tags=["assistant"]) | ||||
| 24 | +MODEL = "claude-opus-5-5" | ||||
| 25 | + | ||||
| 26 | + | ||||
| 27 | +class AskIn(BaseModel): | ||||
| 28 | + question: str = Field(..., min_length=3, max_length=2000) | ||||
| 29 | + max_rows: int = Field(200, ge=1, le=5000) | ||||
| 30 | + execute: bool = True | ||||
| 31 | + | ||||
| 32 | + | ||||
| 33 | +def _catalog_context() -> tuple[str, dict[str, Any]]: | ||||
| 34 | + with connection() as conn: | ||||
| 35 | + ds = conn.execute("SELECT id, name, interval, adjustment, status, current_version, timestamp_convention, timezone FROM datasets WHERE kind IN ('market_bars','derived') ORDER BY id").fetchall() | ||||
| 36 | + cov = conn.execute("SELECT dataset_id, min(session_date) d0, max(session_date) d1, count(DISTINCT symbol) syms FROM coverage GROUP BY 1").fetchall() | ||||
| 37 | + covm = {c["dataset_id"]: c for c in cov} | ||||
| 38 | + lines = ["Available DuckDB views (one per dataset; columns: instrument_id, symbol_as_reported, asset_class, interval, timestamp_utc (bar OPEN, UTC), session_date, open, high, low, close, volume, currency, trade_count, vwap, adjustment_type, source_id, ingested_at, quality_flags, revision_id):"] | ||||
| 39 | + versions = {} | ||||
| 40 | + for d in ds: | ||||
| 41 | + c = covm.get(d["id"]) | ||||
| 42 | + v = f"view {d['id'].replace('.', '_')}: {d['name']} — interval {d['interval']}, adjustment {d['adjustment']}, status {d['status']}, version {d['current_version']}" | ||||
| 43 | + if c: | ||||
| 44 | + v += f", sessions {c['d0']}→{c['d1']}, {c['syms']} symbols" | ||||
| 45 | + else: | ||||
| 46 | + v += ", NO DATA LOADED" | ||||
| 47 | + lines.append(v) | ||||
| 48 | + versions[d["id"]] = d["current_version"] | ||||
| 49 | + lines.append("Fundamentals are NOT available through SQL views (use the fundamentals endpoint); say so if asked.") | ||||
| 50 | + return "\n".join(lines), versions | ||||
| 51 | + | ||||
| 52 | + | ||||
| 53 | +SYSTEM = """You translate research questions about a private market-data lake into ONE read-only DuckDB SQL statement. | ||||
| 54 | +Rules: SELECT/WITH only; use only the listed views and columns; never invent datasets, symbols, dates or columns that are not listed; if the question needs data that is not available (e.g. a dataset with NO DATA LOADED, fundamentals, options, ticks, a date outside coverage), set "sql" to null and explain in "refusal". Prices are raw (unadjusted) unless the view says otherwise. timestamp_utc marks the bar open; convert with timezone 'America/New_York' when the user talks about Eastern time. Always LIMIT results reasonably. | ||||
| 55 | +Respond with JSON only: {"sql": string|null, "explanation": string, "assumptions": [string], "refusal": string|null}.""" | ||||
| 56 | + | ||||
| 57 | + | ||||
| 58 | +def _ask_model(question: str, context: str) -> dict[str, Any]: | ||||
| 59 | + import anthropic | ||||
| 60 | + s = get_settings() | ||||
| 61 | + client = anthropic.Anthropic(api_key=s.anthropic_api_key.get_secret_value(), max_retries=1, timeout=90.0) | ||||
| 62 | + try: | ||||
| 63 | + resp = client.messages.create( | ||||
| 64 | + model=MODEL, max_tokens=4000, output_config={"effort": "medium"}, | ||||
| 65 | + system=[{"type": "text", "text": SYSTEM, "cache_control": {"type": "ephemeral"}}, {"type": "text", "text": context}], | ||||
| 66 | + messages=[{"role": "user", "content": question}], | ||||
| 67 | + ) | ||||
| 68 | + except anthropic.AuthenticationError: | ||||
| 69 | + raise ApiError(502, "provider_auth", "Anthropic rejected the configured key") from None | ||||
| 70 | + except anthropic.RateLimitError: | ||||
| 71 | + raise ApiError(503, "provider_rate_limited", "Anthropic rate limit; retry later") from None | ||||
| 72 | + except anthropic.APIStatusError as e: | ||||
| 73 | + raise ApiError(502, "provider_error", f"Anthropic error {e.status_code}") from None | ||||
| 74 | + except anthropic.APIConnectionError: | ||||
| 75 | + raise ApiError(502, "provider_unreachable", "Could not reach Anthropic") from None | ||||
| 76 | + if resp.stop_reason == "refusal": | ||||
| 77 | + return {"sql": None, "explanation": "", "assumptions": [], "refusal": "The model declined this request."} | ||||
| 78 | + text = "".join(b.text for b in resp.content if b.type == "text").strip() | ||||
| 79 | + if text.startswith("```"): | ||||
| 80 | + text = text.strip("`").split("\n", 1)[1] if "\n" in text else text.strip("`") | ||||
| 81 | + text = text.rsplit("```", 1)[0] | ||||
| 82 | + try: | ||||
| 83 | + out = json.loads(text) | ||||
| 84 | + except json.JSONDecodeError: | ||||
| 85 | + return {"sql": None, "explanation": text[:500], "assumptions": [], "refusal": "Model output was not valid JSON"} | ||||
| 86 | + out["_usage"] = {"input_tokens": resp.usage.input_tokens, "output_tokens": resp.usage.output_tokens, | ||||
| 87 | + "cache_read_input_tokens": getattr(resp.usage, "cache_read_input_tokens", None), "model": resp.model} | ||||
| 88 | + return out | ||||
| 89 | + | ||||
| 90 | + | ||||
| 91 | +@router.get("/status") | ||||
| 92 | +def status(p: A.Principal = Depends(A.require("query"))): | ||||
| 93 | + s = get_settings() | ||||
| 94 | + configured = bool(s.anthropic_api_key.get_secret_value()) | ||||
| 95 | + return {"status": "active" if configured else "pending_configuration", "provider": "anthropic" if configured else None, "model": MODEL if configured else None, | ||||
| 96 | + "note": None if configured else "Set ANTHROPIC_API_KEY in the node .env (server-side BYOK) and run the capability/cost test described in docs/STATUS.md.", | ||||
| 97 | + "guarantees": ["model proposes SQL only; FinData validates and executes in the read-only sandbox", "generated SQL and EXPLAIN plan are always shown", | ||||
| 98 | + "dataset versions used are cited", "questions about data not in the catalog are refused"]} | ||||
| 99 | + | ||||
| 100 | + | ||||
| 101 | +@router.post("") | ||||
| 102 | +def ask(body: AskIn, request: Request, p: A.Principal = Depends(A.require("query"))): | ||||
| 103 | + s = get_settings() | ||||
| 104 | + if not s.anthropic_api_key.get_secret_value(): | ||||
| 105 | + raise ApiError(409, "pending_configuration", "AI assistant is not configured (ANTHROPIC_API_KEY missing on the server)") | ||||
| 106 | + context, versions = _catalog_context() | ||||
| 107 | + t0 = time.monotonic() | ||||
| 108 | + proposal = _ask_model(body.question, context) | ||||
| 109 | + out: dict[str, Any] = {"question": body.question, "sql": proposal.get("sql"), "explanation": proposal.get("explanation"), | ||||
| 110 | + "assumptions": proposal.get("assumptions") or [], "refusal": proposal.get("refusal"), | ||||
| 111 | + "dataset_versions": versions, "model_usage": proposal.get("_usage"), "executed": False} | ||||
| 112 | + if not proposal.get("sql"): | ||||
| 113 | + A.audit(request, "assistant.ask", None, refused=True, question=body.question[:300]) | ||||
| 114 | + return out | ||||
| 115 | + views = dataset_views() | ||||
| 116 | + try: | ||||
| 117 | + plan, _ = run_sql(f"EXPLAIN {proposal['sql']}", 50, 10, views) | ||||
| 118 | + out["plan"] = "\n".join(str(r.get("explain_value") or r) for r in plan.to_pylist()) | ||||
| 119 | + except Exception as e: | ||||
| 120 | + out["refusal"] = f"Generated SQL rejected by the sandbox: {str(e)[:300]}" | ||||
| 121 | + A.audit(request, "assistant.ask", None, rejected=True, question=body.question[:300], sql=proposal["sql"][:500]) | ||||
| 122 | + return out | ||||
| 123 | + if body.execute: | ||||
| 124 | + try: | ||||
| 125 | + tbl, meta = run_sql(proposal["sql"], min(body.max_rows, s.query_max_rows), s.query_timeout_seconds, views) | ||||
| 126 | + except Exception as e: | ||||
| 127 | + out["refusal"] = f"Execution failed: {str(e)[:300]}" | ||||
| 128 | + return out | ||||
| 129 | + rows = tbl.to_pylist() | ||||
| 130 | + for r in rows: | ||||
| 131 | + for k, v in r.items(): | ||||
| 132 | + if hasattr(v, "isoformat"): | ||||
| 133 | + r[k] = v.isoformat() | ||||
| 134 | + out.update({"executed": True, "columns": [{"name": f.name, "type": str(f.type)} for f in tbl.schema], "rows": rows, | ||||
| 135 | + "truncated": meta.get("truncated"), "elapsed_ms": int((time.monotonic() - t0) * 1000)}) | ||||
| 136 | + A.audit(request, "assistant.ask", None, question=body.question[:300], sql=proposal["sql"][:500], rows=len(out.get("rows", []))) | ||||
| 137 | + return out | ||||
modified
services/ingest/src/findata_ingest/reader.py
+2 −1
@@ -139,7 +139,8 @@ def run_sql(sql: str, max_rows: int, timeout_s: int, views: dict[str, list[str]] | |||||
| 139 | timer = threading.Timer(timeout_s, con.interrupt) | 139 | timer = threading.Timer(timeout_s, con.interrupt) | ||
| 140 | timer.start() | 140 | timer.start() | ||
| 141 | try: | 141 | try: | ||
| 142 | − tbl = _arrow(con, f"SELECT * FROM ({sql.strip().rstrip(';')}) q LIMIT {int(max_rows) + 1}", None) | 142 | + body = sql.strip().rstrip(";") | ||
| 143 | + tbl = _arrow(con, body if is_explain else f"SELECT * FROM ({body}) q LIMIT {int(max_rows) + 1}", None) | ||||
| 143 | except duckdb.InterruptException as e: | 144 | except duckdb.InterruptException as e: | ||
| 144 | raise TimeoutError(f"query exceeded {timeout_s}s") from e | 145 | raise TimeoutError(f"query exceeded {timeout_s}s") from e | ||
| 145 | finally: | 146 | finally: | ||
modified
tests/test_z_api_sdk.py
+26 −0
@@ -272,3 +272,29 @@ def test_vendor_secret_never_in_responses_or_openapi(app_client, api_key): | |||||
| 272 | r = c.get("/api/v1/health") | 272 | r = c.get("/api/v1/health") | ||
| 273 | assert r.headers["X-Content-Type-Options"] == "nosniff" and r.headers["X-Frame-Options"] == "DENY" and "default-src 'self'" in r.headers["Content-Security-Policy"] | 273 | assert r.headers["X-Content-Type-Options"] == "nosniff" and r.headers["X-Frame-Options"] == "DENY" and "default-src 'self'" in r.headers["Content-Security-Policy"] | ||
| 274 | assert c.get("/api/docs").status_code == 200 | 274 | assert c.get("/api/docs").status_code == 200 | ||
| 275 | + | ||||
| 276 | + | ||||
| 277 | +def test_assistant_pending_without_key_and_validated_with_stub(app_client, api_key, monkeypatch): | ||||
| 278 | + c = app_client | ||||
| 279 | + H = {"Authorization": f"Bearer {api_key}"} | ||||
| 280 | + st = c.get("/api/v1/assistant/status", headers=H).json() | ||||
| 281 | + assert st["status"] == "pending_configuration" | ||||
| 282 | + r = c.post("/api/v1/assistant", json={"question": "average AAPL close on 2026-10-30"}, headers=H) | ||||
| 283 | + assert r.status_code == 409 and r.json()["error"]["code"] == "pending_configuration" | ||||
| 284 | + # stub the provider: the platform must still validate/execute the SQL itself and refuse unsafe statements | ||||
| 285 | + from findata_api.routers import assistant as AS | ||||
| 286 | + from findata_core.config import get_settings | ||||
| 287 | + from pydantic import SecretStr | ||||
| 288 | + monkeypatch.setattr(get_settings(), "anthropic_api_key", SecretStr("sk-test")) | ||||
| 289 | + monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": "SELECT avg(close) AS avg_close FROM market_bars_stock_1m_raw WHERE symbol_as_reported='AAPL' AND session_date=DATE '2026-10-30'", "explanation": "mean of 1m closes", "assumptions": ["raw prices"], "refusal": None, "_usage": {"input_tokens": 1, "output_tokens": 1}}) | ||||
| 290 | + r = c.post("/api/v1/assistant", json={"question": "average AAPL close on 2026-10-30"}, headers=H) | ||||
| 291 | + assert r.status_code == 200, r.text | ||||
| 292 | + b = r.json() | ||||
| 293 | + assert b["executed"] and 300 < b["rows"][0]["avg_close"] < 400 and "plan" in b and b["dataset_versions"]["market_bars.stock.1m.raw"] >= 1 | ||||
| 294 | + monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": "COPY (SELECT 1) TO '/tmp/x'", "explanation": "", "assumptions": [], "refusal": None}) | ||||
| 295 | + b = c.post("/api/v1/assistant", json={"question": "exfiltrate"}, headers=H).json() | ||||
| 296 | + assert not b["executed"] and "rejected by the sandbox" in b["refusal"] | ||||
| 297 | + monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": None, "explanation": "", "assumptions": [], "refusal": "Options data is not loaded"}) | ||||
| 298 | + b = c.post("/api/v1/assistant", json={"question": "SPY option chain"}, headers=H).json() | ||||
| 299 | + assert b["sql"] is None and "not loaded" in b["refusal"] | ||||
| 300 | + monkeypatch.setattr(get_settings(), "anthropic_api_key", SecretStr("")) | ||||
modified
uv.lock
+182 −0
@@ -41,6 +41,25 @@ wheels = [ | |||||
| 41 | { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, | 41 | { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, | ||
| 42 | ] | 42 | ] | ||
| 43 | 43 | ||||
| 44 | +[[package]] | ||||
| 45 | +name = "anthropic" | ||||
| 46 | +version = "2.3.0" | ||||
| 47 | +source = { registry = "https://pypi.org/simple" } | ||||
| 48 | +dependencies = [ | ||||
| 49 | + { name = "anyio" }, | ||||
| 50 | + { name = "docstring-parser" }, | ||||
| 51 | + { name = "httpx2" }, | ||||
| 52 | + { name = "jiter" }, | ||||
| 53 | + { name = "opentelemetry-api" }, | ||||
| 54 | + { name = "pydantic" }, | ||||
| 55 | + { name = "sniffio" }, | ||||
| 56 | + { name = "typing-extensions" }, | ||||
| 57 | +] | ||||
| 58 | +sdist = { url = "https://files.pythonhosted.org/packages/3e/ba/4c91947e0d0062d66d371db371597287153f7685fbed512d3294d0cde3c7/anthropic-2.3.0.tar.gz", hash = "sha256:edfdea1e3fa7b3ce25df1511953408b2f03bfa61eb6f6268f766101a9e18ad53", size = 1869656, upload-time = "2026-10-23T23:18:47.649Z" } | ||||
| 59 | +wheels = [ | ||||
| 60 | + { url = "https://files.pythonhosted.org/packages/3f/29/eb3558ab6bf252a07ac41c76ac5b0ea24a0aa0c23f87a40aaf31a5f8dba4/anthropic-2.3.0-py3-none-any.whl", hash = "sha256:aa6a3984f9276f307933d42dc6f58b6efc821a5ecafbc8d1bb5993a066767622", size = 2216980, upload-time = "2026-10-23T23:18:45.779Z" }, | ||||
| 61 | +] | ||||
| 62 | + | ||||
| 44 | [[package]] | 63 | [[package]] | ||
| 45 | name = "anyio" | 64 | name = "anyio" | ||
| 46 | version = "4.16.0" | 65 | version = "4.16.0" | ||
@@ -413,6 +432,15 @@ wheels = [ | |||||
| 413 | { url = "https://files.pythonhosted.org/packages/61/55/f90405bbbbd3dd8f788e92cec2d39964f5c09783f0570fc35f3bb3381485/dnspython-2.9.1-py3-none-any.whl", hash = "sha256:7c81caf8de1ed57b8cfc7e0ad4f528a0b8ceace473c7eb946df195dc9def751f", size = 360364, upload-time = "2026-10-29T18:05:10.206Z" }, | 432 | { url = "https://files.pythonhosted.org/packages/61/55/f90405bbbbd3dd8f788e92cec2d39964f5c09783f0570fc35f3bb3381485/dnspython-2.9.1-py3-none-any.whl", hash = "sha256:7c81caf8de1ed57b8cfc7e0ad4f528a0b8ceace473c7eb946df195dc9def751f", size = 360364, upload-time = "2026-10-29T18:05:10.206Z" }, | ||
| 414 | ] | 433 | ] | ||
| 415 | 434 | ||||
| 435 | +[[package]] | ||||
| 436 | +name = "docstring-parser" | ||||
| 437 | +version = "0.18.0" | ||||
| 438 | +source = { registry = "https://pypi.org/simple" } | ||||
| 439 | +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } | ||||
| 440 | +wheels = [ | ||||
| 441 | + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, | ||||
| 442 | +] | ||||
| 443 | + | ||||
| 416 | [[package]] | 444 | [[package]] | ||
| 417 | name = "duckdb" | 445 | name = "duckdb" | ||
| 418 | version = "2.0.0" | 446 | version = "2.0.0" | ||
@@ -489,6 +517,7 @@ name = "findata-api" | |||||
| 489 | version = "0.1.0" | 517 | version = "0.1.0" | ||
| 490 | source = { editable = "services/api" } | 518 | source = { editable = "services/api" } | ||
| 491 | dependencies = [ | 519 | dependencies = [ | ||
| 520 | + { name = "anthropic" }, | ||||
| 492 | { name = "argon2-cffi" }, | 521 | { name = "argon2-cffi" }, | ||
| 493 | { name = "duckdb" }, | 522 | { name = "duckdb" }, | ||
| 494 | { name = "email-validator" }, | 523 | { name = "email-validator" }, | ||
@@ -511,6 +540,7 @@ dependencies = [ | |||||
| 511 | 540 | ||||
| 512 | [package.metadata] | 541 | [package.metadata] | ||
| 513 | requires-dist = [ | 542 | requires-dist = [ | ||
| 543 | + { name = "anthropic", specifier = ">=1.0" }, | ||||
| 514 | { name = "argon2-cffi", specifier = ">=23.1" }, | 544 | { name = "argon2-cffi", specifier = ">=23.1" }, | ||
| 515 | { name = "duckdb", specifier = ">=1.1" }, | 545 | { name = "duckdb", specifier = ">=1.1" }, | ||
| 516 | { name = "email-validator", specifier = ">=2.2" }, | 546 | { name = "email-validator", specifier = ">=2.2" }, | ||
@@ -762,6 +792,19 @@ wheels = [ | |||||
| 762 | { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, | 792 | { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, | ||
| 763 | ] | 793 | ] | ||
| 764 | 794 | ||||
| 795 | +[[package]] | ||||
| 796 | +name = "httpcore2" | ||||
| 797 | +version = "2.16.0" | ||||
| 798 | +source = { registry = "https://pypi.org/simple" } | ||||
| 799 | +dependencies = [ | ||||
| 800 | + { name = "h11", marker = "sys_platform != 'emscripten'" }, | ||||
| 801 | + { name = "truststore", marker = "sys_platform != 'emscripten'" }, | ||||
| 802 | +] | ||||
| 803 | +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } | ||||
| 804 | +wheels = [ | ||||
| 805 | + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, | ||||
| 806 | +] | ||||
| 807 | + | ||||
| 765 | [[package]] | 808 | [[package]] | ||
| 766 | name = "httptools" | 809 | name = "httptools" | ||
| 767 | version = "0.9.0" | 810 | version = "0.9.0" | ||
@@ -863,6 +906,32 @@ wheels = [ | |||||
| 863 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, | 906 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, | ||
| 864 | ] | 907 | ] | ||
| 865 | 908 | ||||
| 909 | +[[package]] | ||||
| 910 | +name = "httpx2" | ||||
| 911 | +version = "2.16.0" | ||||
| 912 | +source = { registry = "https://pypi.org/simple" } | ||||
| 913 | +dependencies = [ | ||||
| 914 | + { name = "anyio", marker = "sys_platform != 'emscripten'" }, | ||||
| 915 | + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, | ||||
| 916 | + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, | ||||
| 917 | + { name = "idna" }, | ||||
| 918 | + { name = "truststore", marker = "sys_platform != 'emscripten'" }, | ||||
| 919 | + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, | ||||
| 920 | +] | ||||
| 921 | +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } | ||||
| 922 | +wheels = [ | ||||
| 923 | + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, | ||||
| 924 | +] | ||||
| 925 | + | ||||
| 926 | +[[package]] | ||||
| 927 | +name = "httpx2-jsfetch" | ||||
| 928 | +version = "1.0" | ||||
| 929 | +source = { registry = "https://pypi.org/simple" } | ||||
| 930 | +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } | ||||
| 931 | +wheels = [ | ||||
| 932 | + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, | ||||
| 933 | +] | ||||
| 934 | + | ||||
| 866 | [[package]] | 935 | [[package]] | ||
| 867 | name = "idna" | 936 | name = "idna" | ||
| 868 | version = "3.20" | 937 | version = "3.20" | ||
@@ -890,6 +959,101 @@ wheels = [ | |||||
| 890 | { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, | 959 | { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, | ||
| 891 | ] | 960 | ] | ||
| 892 | 961 | ||||
| 962 | +[[package]] | ||||
| 963 | +name = "jiter" | ||||
| 964 | +version = "0.18.0" | ||||
| 965 | +source = { registry = "https://pypi.org/simple" } | ||||
| 966 | +sdist = { url = "https://files.pythonhosted.org/packages/63/5b/f68319f4fe624311a744a33d10fdbcac4c971991e348223cba60d31780b2/jiter-0.18.0.tar.gz", hash = "sha256:a0ca63fd9450a212e4e658d4cfe0ab4977c4f9b0003c68828b843c65e27662ff", size = 234470, upload-time = "2026-10-23T13:51:13.103Z" } | ||||
| 967 | +wheels = [ | ||||
| 968 | + { url = "https://files.pythonhosted.org/packages/45/0e/075bfa4fa46820da9ac8cf8a67c435b0586012279fba15deccde0e73b9ce/jiter-0.18.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:77a01cdf4381a1becbe5b827f682006f47b712581667451c5b1eb259b9ae7041", size = 290095, upload-time = "2026-10-23T13:49:14.118Z" }, | ||||
| 969 | + { url = "https://files.pythonhosted.org/packages/2e/e2/f4000a4d9c519ca019bf0e2922cc0c6b6db347be6303161382eda7635be7/jiter-0.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b41cd64ee6b54db94bea6274e6e37002ed526aa06c114a8be6f36ffe21c184e", size = 316319, upload-time = "2026-10-23T13:49:15.263Z" }, | ||||
| 970 | + { url = "https://files.pythonhosted.org/packages/51/93/9aaef4d18d19446fd044b9cff0323b1b45b5526f077f1a80418567a6eef8/jiter-0.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7424194e68ef44e94290961f59319850d7ce11a329a3957f91709c15ded6618", size = 335631, upload-time = "2026-10-23T13:49:16.551Z" }, | ||||
| 971 | + { url = "https://files.pythonhosted.org/packages/fd/9e/d2dc5e8179e248df7ef1c7c8822554f4c7f694a33be30790bb523f1e657a/jiter-0.18.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04a484fce032a8b2c21dec0410a805d7b16573eb34297156a3b4d242c507d2b4", size = 322596, upload-time = "2026-10-23T13:49:17.875Z" }, | ||||
| 972 | + { url = "https://files.pythonhosted.org/packages/22/ca/28bfd20743be0b2b37c1ddfe069a637d5180e535f1c8faad555cf7ac0b98/jiter-0.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:860ac97858116bda0ac02ef5f50c541fc1a84ad7b9368cac68d4de5697a9012d", size = 333858, upload-time = "2026-10-23T13:49:19.343Z" }, | ||||
| 973 | + { url = "https://files.pythonhosted.org/packages/69/89/e42f7dba54dcf7787aa4be3e22cdcb1f14d9635b6527949652ab490d6f8e/jiter-0.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39ef656cf371971486ec545ca0ae2da8d037d5479bc906121e3e46de86ee73c4", size = 348656, upload-time = "2026-10-23T13:49:20.796Z" }, | ||||
| 974 | + { url = "https://files.pythonhosted.org/packages/74/6a/b13407f609ddaf9a1f0cdf1336326dc5c8c7fc56943e8450a04a542f5edf/jiter-0.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3c741e6b778c951a6861964ee475118812a33208426c54fb92bf4e318bda834", size = 336286, upload-time = "2026-10-23T13:49:22.152Z" }, | ||||
| 975 | + { url = "https://files.pythonhosted.org/packages/b2/7e/b560f97495a485a569e7fa338a13587328854f6a40cd2e93602c327ca4a6/jiter-0.18.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:1277ac083a58d2f49a24ff9a28fd9876a6443a1992d4467a1db80fb45e354b18", size = 314415, upload-time = "2026-10-23T13:49:23.645Z" }, | ||||
| 976 | + { url = "https://files.pythonhosted.org/packages/8b/97/457b6ec16f1f3f25ec760f23728f43a2b20ddf934fcd8f02c81a2183597e/jiter-0.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ccf224d2e706be81d464b59c2bc3f98ba65d12c092c1789e625127e64488944e", size = 332078, upload-time = "2026-10-23T13:49:24.917Z" }, | ||||
| 977 | + { url = "https://files.pythonhosted.org/packages/0e/d9/6f616071650f7bb8fcbce674d4dde7e59cfbd8a4d30f5c71c03630425d6e/jiter-0.18.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5c7fc8e5357d1ad082bb177be71e398fcd3d458726439255ca5cb0d706060269", size = 480354, upload-time = "2026-10-23T13:49:26.221Z" }, | ||||
| 978 | + { url = "https://files.pythonhosted.org/packages/42/72/52a3630c8213cdc8093cc86dc6ad50f2bc89ccfa97ef272205d38a24e16d/jiter-0.18.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a6bba0404b46157a651d1fe512cf2c59c8e42836a9284c35cdff35310758ba4f", size = 515322, upload-time = "2026-10-23T13:49:27.652Z" }, | ||||
| 979 | + { url = "https://files.pythonhosted.org/packages/31/54/987cfee52bdf8a286e9c1c58192a9bb64f8773a6be36e4ec46959f67fb7e/jiter-0.18.0-cp312-cp312-win32.whl", hash = "sha256:07ae058bc052ead609b49bbae78c01bf4dee8f5f9d21705cbae9bfe59b33efc9", size = 185341, upload-time = "2026-10-23T13:49:29.147Z" }, | ||||
| 980 | + { url = "https://files.pythonhosted.org/packages/c5/c9/b1279eecf68e5f62c918af3f7f856a071ae7db0c90b8fd2235b1b6020c8c/jiter-0.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:79d1fe529bd7065c6f3f50279a08c2ab82eaa0631a6f5dfafa9c65105eeaaafa", size = 225889, upload-time = "2026-10-23T13:49:30.306Z" }, | ||||
| 981 | + { url = "https://files.pythonhosted.org/packages/e0/14/715f84be58a7fc97779aa40c37164acfa25ccb2cee89bfb69358e7071ff3/jiter-0.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f8b3b3d8761dee3cddcb5d3e99eaccc0155bbefc345affcdedcc758a4933a1f", size = 181852, upload-time = "2026-10-23T13:49:31.811Z" }, | ||||
| 982 | + { url = "https://files.pythonhosted.org/packages/50/94/aa275cdb099fd84e402977f8fc8628e2cee2f7c8eafe1d1d5c5e722f5ee5/jiter-0.18.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1517648b5f6268513dea206241a99d9991d6ffcbdbc406848e77a7f02ed3ccf1", size = 289648, upload-time = "2026-10-23T13:49:33.059Z" }, | ||||
| 983 | + { url = "https://files.pythonhosted.org/packages/8d/30/3eaf67279c6183393d3608b6713c93668ae82ea59f0589ea59d8176d8741/jiter-0.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7161217e2190b9b2c8c79b81699be29d67010f6f77e09a53a3b62261792d5489", size = 315919, upload-time = "2026-10-23T13:49:34.457Z" }, | ||||
| 984 | + { url = "https://files.pythonhosted.org/packages/f8/2b/d18adcfec3516b97b24c0d5cd1b0ced22cada8ea9806f21bece75af49ffe/jiter-0.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9623fc90f1a4754452a525383ce70476476c16556098944709b8cdbd9ad905f0", size = 335231, upload-time = "2026-10-23T13:49:35.776Z" }, | ||||
| 985 | + { url = "https://files.pythonhosted.org/packages/41/e8/42cff1077883ea5311621dc9384a1ede4799bc3c01411798ac75f7a71f15/jiter-0.18.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9083a7c2a4ee6358bc0401c46b4d5090f208f6fadfb6275c7b5fe3217e13506", size = 321590, upload-time = "2026-10-23T13:49:36.997Z" }, | ||||
| 986 | + { url = "https://files.pythonhosted.org/packages/1d/4d/2fbedfce563223d4783a872cead5f5bb74d2d1e418f963410ca28e4b7394/jiter-0.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4632f5172800da4cd42fd53f4553c772acd08f1c33dd7ac135e29d735080fa0d", size = 332960, upload-time = "2026-10-23T13:49:38.615Z" }, | ||||
| 987 | + { url = "https://files.pythonhosted.org/packages/04/a0/b082b2d8bd3bef9a888cc0a0f060a24644f24b5195f2e8227a40507e6b71/jiter-0.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c00b1f8fc1dd06c610305be36778b38148ed1e9a532bdd81189c3c0d9905c888", size = 348149, upload-time = "2026-10-23T13:49:39.826Z" }, | ||||
| 988 | + { url = "https://files.pythonhosted.org/packages/64/11/babc8d3c004bb2f11b7e3adfe27a5bc7c1e13cdfc6360d424c57355c55ff/jiter-0.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e3350b7431f7b8315b925d9ca0f29a44e8fb6ed45c1fe552392cb6297966bb9", size = 335978, upload-time = "2026-10-23T13:49:41.165Z" }, | ||||
| 989 | + { url = "https://files.pythonhosted.org/packages/3b/21/c50bf83c39d60f0df896ebcb025b316b670a703397d08bea2c69b9615101/jiter-0.18.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3b49071fb7917cdbc6d80c81a491c6ad146c1a8fd6cb3076cd3c1bfeb6e266f6", size = 313767, upload-time = "2026-10-23T13:49:42.538Z" }, | ||||
| 990 | + { url = "https://files.pythonhosted.org/packages/af/19/3f66480d5bfd3f49571a073eefac4d890dd590a8eb3c78b15e0293909cc6/jiter-0.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04e210f5ddcccdbde484821040216a112381e6484abc549c20c38e9698ab07ca", size = 331070, upload-time = "2026-10-23T13:49:43.782Z" }, | ||||
| 991 | + { url = "https://files.pythonhosted.org/packages/5a/3d/b5662c84b14475c8b018ed1ca2ea44069194b7d97a03bac81ae6b46f6b9f/jiter-0.18.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5b339f6ae14fe7be92f391cc65cc5c80ff76e8da76d63b18fc14664f09b7cece", size = 479939, upload-time = "2026-10-23T13:49:45.033Z" }, | ||||
| 992 | + { url = "https://files.pythonhosted.org/packages/80/b8/081c0414379ee2564aa7808f78e963538a2c4aed2f8d31775e2423e1cdbf/jiter-0.18.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:5cb43336244e063f9cc11e44730e4b92dea70c6eb9b649d268c5ebebc4bc7685", size = 514734, upload-time = "2026-10-23T13:49:46.236Z" }, | ||||
| 993 | + { url = "https://files.pythonhosted.org/packages/0f/1c/6f7bf98786851ae424a1d88562586302536cc137848fd12d2add3960fab3/jiter-0.18.0-cp313-cp313-win32.whl", hash = "sha256:9656d0e9001f69c4af6523874ff445ab4e5db7b835f59733cc5ff51e73a9aa57", size = 184699, upload-time = "2026-10-23T13:49:47.398Z" }, | ||||
| 994 | + { url = "https://files.pythonhosted.org/packages/38/53/5803216c024a241a0bf41ed2668713d7228f92c5593e40d5e873e3b20e0b/jiter-0.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f26198220165eb9627847401fff4d0ca8bebf3ef840a88c6312918d2291c33a", size = 222493, upload-time = "2026-10-23T13:49:48.533Z" }, | ||||
| 995 | + { url = "https://files.pythonhosted.org/packages/fe/8e/484919ccb041c9a1e84059be02bd37ff086b8b084e21630e0958d3ae41a3/jiter-0.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:bef1fe687dc6af17bef9f644a95d465f15f0efa20b743c858e0b6a14e2c0c73e", size = 181250, upload-time = "2026-10-23T13:49:49.777Z" }, | ||||
| 996 | + { url = "https://files.pythonhosted.org/packages/74/0e/45a502771d73d4a939132d8bf85d7b049c7ca06a07f84b414b525c3dc084/jiter-0.18.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:248e939f67a9f8030e2624260a9f417decfe275af37aa96728d256468a9c4086", size = 291757, upload-time = "2026-10-23T13:49:50.95Z" }, | ||||
| 997 | + { url = "https://files.pythonhosted.org/packages/21/f6/d9cfa2bf2a969fb8e002fb8d8a6cd98d49a6248cb7e4ffe2247766af13fb/jiter-0.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0390fa550fcafbf69fb97ccb287bd71192f90410a1c7dd600ca4000aa9aabcf2", size = 318585, upload-time = "2026-10-23T13:49:52.407Z" }, | ||||
| 998 | + { url = "https://files.pythonhosted.org/packages/46/38/ea1dae56f123e3a864188cbbc577314293c0dcc019a880fa9c0a8097934a/jiter-0.18.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8013186f2b9a8c27ededa601050392527aa9d27ffd35d49aae424fbd2062f38", size = 338099, upload-time = "2026-10-23T13:49:53.658Z" }, | ||||
| 999 | + { url = "https://files.pythonhosted.org/packages/1f/d9/fcc5c53a32f98d52f4fd46fda62b69f5c7c2c975a582c0e18501250a4f78/jiter-0.18.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd4316df8e2074fcf5173f6001f1fc9596e7c1e8c4bf499b02f9ebe92babdfb1", size = 324016, upload-time = "2026-10-23T13:49:54.999Z" }, | ||||
| 1000 | + { url = "https://files.pythonhosted.org/packages/d4/63/c24cd08907f08e6a3ba3173616ad268a3d32dd81c63ba34b991d08577d28/jiter-0.18.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9e474da7a9f65d3b460cca93cc8a0f4ec0220d1fa41f1f7a3709195ead60056", size = 335904, upload-time = "2026-10-23T13:49:56.214Z" }, | ||||
| 1001 | + { url = "https://files.pythonhosted.org/packages/0b/78/19ca1c8fe87bd3c8540b91504ff2dd52c9c538233044c4cf787fc1fab39b/jiter-0.18.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d55acd326db8ecfc94f21951abd20902c7939d35925444e48d94527e287bead7", size = 350523, upload-time = "2026-10-23T13:49:57.552Z" }, | ||||
| 1002 | + { url = "https://files.pythonhosted.org/packages/c3/27/84be9cc1c2b781d2da7c694fd7608311af3aaab91f20eb00d4999285b351/jiter-0.18.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a01642d403b8925f2b1e1bb51b4ba49ece8a2bdc9b9560fc8ed0b1f2b35b567a", size = 338324, upload-time = "2026-10-23T13:49:58.842Z" }, | ||||
| 1003 | + { url = "https://files.pythonhosted.org/packages/9a/ac/7b3bfb49b19e42ebc2ae56ec307c372602dd1e17892f7e051c9bf97cd7e6/jiter-0.18.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:89ba6b674d4bd7290f9943bbeba0e1bf35955bbb1e07e41ba94691648cf133d5", size = 316484, upload-time = "2026-10-23T13:50:00.257Z" }, | ||||
| 1004 | + { url = "https://files.pythonhosted.org/packages/f9/6a/e37f50885aa4daa1dfe413784692ead6b48a1d9e857cd394a61bf345189c/jiter-0.18.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:feadebdfee32c61e91066433f4aa74c891752e26e43032c1feef08dda6d9b6ed", size = 334225, upload-time = "2026-10-23T13:50:01.542Z" }, | ||||
| 1005 | + { url = "https://files.pythonhosted.org/packages/8b/f8/1026bc92f0fd443a1137993d1448a7d7c4b7a34bec2722a1533302ebc4b4/jiter-0.18.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:dd7e57efd54a3580620f635c48a3226f910e23649b7eb83ffbc6f0f3386293b8", size = 482157, upload-time = "2026-10-23T13:50:02.849Z" }, | ||||
| 1006 | + { url = "https://files.pythonhosted.org/packages/1b/2d/61f3c35736873c34eeee3d0008067febd88ded8e8cf6f9a0ab090348a787/jiter-0.18.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ea69e23481497c2143775a98d273ccc43de5c92a6387df18c903d2d37bc2b181", size = 516956, upload-time = "2026-10-23T13:50:04.19Z" }, | ||||
| 1007 | + { url = "https://files.pythonhosted.org/packages/6b/bf/9d14be58e76324f4927c7f8e5dbe2f9fd04faaec884a6465173f8142eac1/jiter-0.18.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:d9b61af76a3cd90ad9656a331737d97dab8c05facb2d04cb41fb16c9adadc966", size = 112087, upload-time = "2026-10-23T13:50:05.423Z" }, | ||||
| 1008 | + { url = "https://files.pythonhosted.org/packages/c6/7d/cff751c560d514da283bae595fe154714f285ec5ac15e2b16e9e0c0c8948/jiter-0.18.0-cp314-cp314-win32.whl", hash = "sha256:fe42726d7fe171cd8fc1966d4457a6aae03bd408d519d2cc5e7726805460b51e", size = 186562, upload-time = "2026-10-23T13:50:06.81Z" }, | ||||
| 1009 | + { url = "https://files.pythonhosted.org/packages/04/70/4e7f51e517be4146400fd1ce642d535380a38c7db5ab475d38bbcd16a82d/jiter-0.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:f1e738b851019d6bb6ff4a42c6319bdede967dec958ef3ec6feb7ab9d12408f7", size = 225448, upload-time = "2026-10-23T13:50:08.302Z" }, | ||||
| 1010 | + { url = "https://files.pythonhosted.org/packages/78/43/02bfb85f63e04c87c406731197e41f2e6b415035e41497428e0422fb326e/jiter-0.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:80a961b1c5c7786bc03b332deb7f7ebe818b7baa6e26685e2bf480548ca128b4", size = 182907, upload-time = "2026-10-23T13:50:09.795Z" }, | ||||
| 1011 | + { url = "https://files.pythonhosted.org/packages/9c/66/5cbde35cbb14c8339af39c9bc61cbda08b212116f96603d23b7c9cc84a4d/jiter-0.18.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b066f37dd9ec19fc7931d79dee38f56b6f849217bbe824a7dad4518ef96ee515", size = 318962, upload-time = "2026-10-23T13:50:10.91Z" }, | ||||
| 1012 | + { url = "https://files.pythonhosted.org/packages/eb/d1/dccc47ed6070d3b25622823048a80a49fe87ccd737e5d66b802ca20e4fde/jiter-0.18.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccdc09887b04f50f1761eedcfd19da882d9d834096bc1215049af637023f7db7", size = 336718, upload-time = "2026-10-23T13:50:12.149Z" }, | ||||
| 1013 | + { url = "https://files.pythonhosted.org/packages/de/a5/8d4ec06d7a0f2bccbde8508ed847ccd323a9dd1fbab5900cda3fc5bd71f8/jiter-0.18.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6133abcd0f332964fbb8145adb1ce775cd0312c9eaf6e4bf26b7a5c9b6f7cca7", size = 322945, upload-time = "2026-10-23T13:50:13.591Z" }, | ||||
| 1014 | + { url = "https://files.pythonhosted.org/packages/7f/50/49d06214b30c685d64ffdd40fdb50a98b6ccff4b012fc9ed491d44de9ef1/jiter-0.18.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca381b4a466023d1fdd04c889c759b46f7d9654f622b28bb01e65abf31d07bba", size = 335325, upload-time = "2026-10-23T13:50:14.807Z" }, | ||||
| 1015 | + { url = "https://files.pythonhosted.org/packages/f4/9a/4ecb866a7a01a4e76d9ffbde3fac2cd66ad978c8c797953d44f91fbf2040/jiter-0.18.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b961fedf589ffa182fe302fc4371acf4606d3ed4a6a48a151fbfd724d811f5a", size = 350578, upload-time = "2026-10-23T13:50:16.125Z" }, | ||||
| 1016 | + { url = "https://files.pythonhosted.org/packages/16/7e/c8b5ecb6af4c9454376263f167f3f8bb5dec41b10fac4f48ea4557b9f115/jiter-0.18.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b77eabfbae1ac47872be0076899e66340bbef39b862971c3d6c9a4b7c91fd10", size = 338791, upload-time = "2026-10-23T13:50:17.703Z" }, | ||||
| 1017 | + { url = "https://files.pythonhosted.org/packages/8a/04/45ba31ce79022706323fd2291f87d956fd51fdfcba2ba8924b3de62f0ce2/jiter-0.18.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92a16933d0fa852133500837d33cd6a4f75e4414d77389c58269e12bafc2ee02", size = 316169, upload-time = "2026-10-23T13:50:18.915Z" }, | ||||
| 1018 | + { url = "https://files.pythonhosted.org/packages/62/c1/fd36126144f4c32d1b76939278e9d3b7b798075c0478b641d6066621bafd/jiter-0.18.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f7ba518893a4ccef5afb38e41bc3e126bf652354417ec4e75e5a5b000fb1ef6", size = 334754, upload-time = "2026-10-23T13:50:20.169Z" }, | ||||
| 1019 | + { url = "https://files.pythonhosted.org/packages/cc/e2/30a5ac1a3ee9e01bda75a4fcb55bbeddcf8875f8ce7e680b787c177f14b5/jiter-0.18.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:04d67a44b040a6662a35edc9869030f7aa566bf5baaf3549c99d847aa44213e2", size = 481094, upload-time = "2026-10-23T13:50:21.481Z" }, | ||||
| 1020 | + { url = "https://files.pythonhosted.org/packages/33/de/5e736a36eff6933da012dc440540f79e3ccfc06e27f557638280657df023/jiter-0.18.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e4d4819398d5330636bc0b91ccc0048dba782076fb99257038257ba6f121c840", size = 515524, upload-time = "2026-10-23T13:50:22.872Z" }, | ||||
| 1021 | + { url = "https://files.pythonhosted.org/packages/0a/ea/1e8bc5b0c920a13e5b698106dbb2d2da5c5e3474668746246f649abb6d5f/jiter-0.18.0-cp314-cp314t-win32.whl", hash = "sha256:c435e795a7ea4b372166a058a5b49a25caeb9c94e469a1414868b14c83ee5c41", size = 185058, upload-time = "2026-10-23T13:50:24.098Z" }, | ||||
| 1022 | + { url = "https://files.pythonhosted.org/packages/1e/0f/a742b397699c3af52dd95443332164884fe6204e5faa5321b7c073beab0f/jiter-0.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5a3d5397da7ae46c7a9601ef02d2c9ec47a6c3abe15e1ea940eaf93e675ee20e", size = 225598, upload-time = "2026-10-23T13:50:25.44Z" }, | ||||
| 1023 | + { url = "https://files.pythonhosted.org/packages/b3/b7/08ae26347dca48d5cadca33be2f2f5b6b1ae790b322109083d6f95959feb/jiter-0.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4b81b474e4a8ec745ab20a6762275a360e1147cb70cf08872ec14e1aa5debc37", size = 183052, upload-time = "2026-10-23T13:50:26.675Z" }, | ||||
| 1024 | + { url = "https://files.pythonhosted.org/packages/c2/d6/0d2a8a9747ad918141827fabdc05abc3bf73986f7f36647fb8c19708e21e/jiter-0.18.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:30f8ae18425e0247ffff4e4a5753ec1952e1b81ed4e7949b84e5ecfaf8315833", size = 291908, upload-time = "2026-10-23T13:50:27.94Z" }, | ||||
| 1025 | + { url = "https://files.pythonhosted.org/packages/54/b4/19c582727e22681c6069db037c3b3e354c6fbdce7fbc5290a7bc7bc5884f/jiter-0.18.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:93150a3bb0317d8f5a94d4c3ea68db1cb434b94ac819e08c443cea97b7645f2f", size = 316544, upload-time = "2026-10-23T13:50:29.167Z" }, | ||||
| 1026 | + { url = "https://files.pythonhosted.org/packages/c3/62/5d34e93dc5d1e707788a378335efc876cdc731ceb2111b0a9d7c6f9fdada/jiter-0.18.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c8a61d6227cdcb0a67c678f30a020837c9f3dd7e0b3f916781defa157e4eb9", size = 338101, upload-time = "2026-10-23T13:50:30.457Z" }, | ||||
| 1027 | + { url = "https://files.pythonhosted.org/packages/75/e2/0b16a6ca5479cd9efa3c59497cd42f1979a539b6c664a5b9c87e3632f176/jiter-0.18.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:285cc15ff9ffcd4ce7f0b9ce02fcbc65e22fcf7dfbda1a3d71ccac8d7b73f8a1", size = 324213, upload-time = "2026-10-23T13:50:31.695Z" }, | ||||
| 1028 | + { url = "https://files.pythonhosted.org/packages/85/9d/620a97457a0210092bf9faa1eb30f5da094fab2de07a9478af7c254e22fd/jiter-0.18.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8f69ef2e28f8c1759bc1522385ab3e4ade0b1056053cecf67449a947c4525a8", size = 335777, upload-time = "2026-10-23T13:50:32.984Z" }, | ||||
| 1029 | + { url = "https://files.pythonhosted.org/packages/76/08/a3514df093824f10a61dc1d0bbc22285ed779ba42f15d727499a8c877e16/jiter-0.18.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eec2da60423287bd60377b0fad1723560dcc51cfb2b99615be8431ad425c4438", size = 350490, upload-time = "2026-10-23T13:50:34.421Z" }, | ||||
| 1030 | + { url = "https://files.pythonhosted.org/packages/cd/60/8f8b51bae62259b429e83d0adff69d93e39e4fadbc4bd1856dc2a0950639/jiter-0.18.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5db2cdae6a2f01e8f52ab2154886dcf5bb1844b4f31245440bf623ec91b65b08", size = 338591, upload-time = "2026-10-23T13:50:35.759Z" }, | ||||
| 1031 | + { url = "https://files.pythonhosted.org/packages/2e/ed/5307f0eb051d987510fdd1020c904927c0b364a4937d372817bdefaced31/jiter-0.18.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:029da1f29852beac2f9001f20209b73b29b4d7bdc748c7f8028895769e2cc0d8", size = 316623, upload-time = "2026-10-23T13:50:37.316Z" }, | ||||
| 1032 | + { url = "https://files.pythonhosted.org/packages/f9/0e/4bdc945ffbf77c60f07c726fbeb45508018322df9d9942d262256397062e/jiter-0.18.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:67c6662b1b7e5728a507db06ad1b8a7ff8ade04c2a74f092d467209ef744d3a4", size = 334720, upload-time = "2026-10-23T13:50:38.507Z" }, | ||||
| 1033 | + { url = "https://files.pythonhosted.org/packages/4b/33/fb3e8f9354e7edab05f3752383eac58ce6e785f2f1626b697dd5dd0a86e4/jiter-0.18.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:4db12daef4a231df528a16f70d5ff7f5e3be21f70e8ad904e4c61442366e8a10", size = 482130, upload-time = "2026-10-23T13:50:40.023Z" }, | ||||
| 1034 | + { url = "https://files.pythonhosted.org/packages/2e/3d/3aef2be95155bbacadd8691208b6f0f7371cc0cefe80241a1245e38e308d/jiter-0.18.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:4654ea93f525ea96adf289afcf1ead32d773b8325ec326862b4f81f5f4570835", size = 516986, upload-time = "2026-10-23T13:50:41.279Z" }, | ||||
| 1035 | + { url = "https://files.pythonhosted.org/packages/3c/f6/2aa54fd5da3d0a93de2655e2525426263168454259ba3f2fb70d1b36fbc9/jiter-0.18.0-cp315-cp315-win32.whl", hash = "sha256:f035d01b450ac4e141502f1ce142462cee638198ff5a0237c80e6683798b3b87", size = 186645, upload-time = "2026-10-23T13:50:42.526Z" }, | ||||
| 1036 | + { url = "https://files.pythonhosted.org/packages/b5/77/5b54ca8c292b83991cafbac6b022f515e46f1ae77dd5f4a97cc33644b235/jiter-0.18.0-cp315-cp315-win_amd64.whl", hash = "sha256:4a18ef4fe95685b000d95eca797cc683e0c178a4b364f2e39f6c79ab107a26e6", size = 225087, upload-time = "2026-10-23T13:50:43.779Z" }, | ||||
| 1037 | + { url = "https://files.pythonhosted.org/packages/9f/f3/53b7f39b83c757380ab57d9bc3f934431d6b6751f007e8122f7f63c10c2c/jiter-0.18.0-cp315-cp315-win_arm64.whl", hash = "sha256:c73922e4ff4b1ad2929cd795c98223c6688a43c5fd42e9deb8955e4d43b00abb", size = 183120, upload-time = "2026-10-23T13:50:44.985Z" }, | ||||
| 1038 | + { url = "https://files.pythonhosted.org/packages/77/f8/3a3ef55c528722f3b4be0e7b9686a37ddbe86ed96510bc1c2a63dab7f14b/jiter-0.18.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0ccf55f6fcdcee82fe40ae617e9d64b8e5f433fa6cb5bf7d2969e584e6df0e5d", size = 317996, upload-time = "2026-10-23T13:50:46.266Z" }, | ||||
| 1039 | + { url = "https://files.pythonhosted.org/packages/e1/15/b06c1c17bed30b5c5dabb40f80a56a3b71b89c16ae86552307f7ccedc93a/jiter-0.18.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:163a55e77f8e92d739b1f4ef212f1d445ddc58ecdf0da1feca44ef424b5331f8", size = 336638, upload-time = "2026-10-23T13:50:47.444Z" }, | ||||
| 1040 | + { url = "https://files.pythonhosted.org/packages/87/79/b7bcd3e8c850326a323d18a5607334ea40659e22a4fc321e315d1d230007/jiter-0.18.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9de6c9133be12a51ea2d8c270f39a04ed45ee369802bb6270649b952e6cec256", size = 323116, upload-time = "2026-10-23T13:50:48.715Z" }, | ||||
| 1041 | + { url = "https://files.pythonhosted.org/packages/5e/99/4c53c9d46462cd0691c3c188f89612e63a424d19f5befd1b0631f8f90638/jiter-0.18.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ea4f2ed18eba7454182d4ff727444788ae51ad11bf5a25ad892f5f0ad3e754b", size = 335162, upload-time = "2026-10-23T13:50:49.987Z" }, | ||||
| 1042 | + { url = "https://files.pythonhosted.org/packages/6f/ab/ef873065a3a1c442a4e62558b77bfa338066317dbafdfdc991121201f5ad/jiter-0.18.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:626d5c9fb8b57babc6ce9d03224846ecb094e1d22ff5bff6080398e7f8aee162", size = 350560, upload-time = "2026-10-23T13:50:51.478Z" }, | ||||
| 1043 | + { url = "https://files.pythonhosted.org/packages/19/7e/48e78558d7cb3d289211da0397b7ed31cfe3efc98dd85f2324abd574c07c/jiter-0.18.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3c3318cc6ef857b6554b43479c30e8249fc143189d500d4137cde9c58412e6d", size = 337848, upload-time = "2026-10-23T13:50:52.961Z" }, | ||||
| 1044 | + { url = "https://files.pythonhosted.org/packages/2c/35/b0a093e72def7caec970dd8536c3d42af3add3aedfb3c06ac0588effde26/jiter-0.18.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:9a70f25f6de76a01e309e109c9bcf2143c5c126b3488dd17a25c7aa404b28f16", size = 315870, upload-time = "2026-10-23T13:50:54.152Z" }, | ||||
| 1045 | + { url = "https://files.pythonhosted.org/packages/48/a4/fd7ca4d1ce8b876be7a260f5e31615854aed5b041553a63e85c4a265cb63/jiter-0.18.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22d8dd757e603224e46786d63cb7d7bfbddc8f579a02e7b082840468d18c06f3", size = 335253, upload-time = "2026-10-23T13:50:55.427Z" }, | ||||
| 1046 | + { url = "https://files.pythonhosted.org/packages/f5/71/ccd7154afecd4687b22a8195d5dc6460f89f6e17537ec78edb58a2faac34/jiter-0.18.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:552a8714f26ddcb43115c82da6d045b3b9f2fa5997d8b21b2f8ec3721503bebb", size = 480775, upload-time = "2026-10-23T13:50:56.91Z" }, | ||||
| 1047 | + { url = "https://files.pythonhosted.org/packages/c8/b9/bad5391d7fb734cd6ebc383c8603bc324518bf4ce865f5d813908ae42596/jiter-0.18.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:8060a206e290410d3bfc3bd91715f3ec90a499246f8335d48927e0b3b6a6ccc5", size = 515619, upload-time = "2026-10-23T13:50:58.231Z" }, | ||||
| 1048 | + { url = "https://files.pythonhosted.org/packages/c8/5c/9596452ad56340e6ed395c5fe27ab102bb2aa0c4b59de2e6a8b3dc3b03a7/jiter-0.18.0-cp315-cp315t-win32.whl", hash = "sha256:91d463858f11ee76ce8e39f391bbcbf1d4c3cc4703a1496fed3a5666dcd5e01d", size = 185204, upload-time = "2026-10-23T13:50:59.456Z" }, | ||||
| 1049 | + { url = "https://files.pythonhosted.org/packages/08/c8/62c798e2a1a6f827eac1d45232c93b1b0e387a22d559c8483dc7302d5fd5/jiter-0.18.0-cp315-cp315t-win_amd64.whl", hash = "sha256:6ee49daab0b826279c152ad8d61171ea17f4197f91a5e14251d0d5d4adf51059", size = 224948, upload-time = "2026-10-23T13:51:00.624Z" }, | ||||
| 1050 | + { url = "https://files.pythonhosted.org/packages/64/42/cdfa494914b6d512431e795d8d6226b84b77b733bb8b5eccafd5a1a86dad/jiter-0.18.0-cp315-cp315t-win_arm64.whl", hash = "sha256:bec676a0dc24d4dabd4bdfb3d1f1bf80d4d3dd892fc5c5b3f8f953e2459dbfce", size = 183182, upload-time = "2026-10-23T13:51:01.797Z" }, | ||||
| 1051 | + { url = "https://files.pythonhosted.org/packages/a0/c2/49ee8f4743c27e61c52b4746ba636b43bedda97131a14043dc6f5ce5bb9d/jiter-0.18.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:118c1fd995f349f1cad0557f31d6860f336b4aeef7f002dedbc068027f68e2c3", size = 287302, upload-time = "2026-10-23T13:51:08.073Z" }, | ||||
| 1052 | + { url = "https://files.pythonhosted.org/packages/60/72/394036294ac3226216f73bfa4b14df920eb5baf7fb80fa7e0a0d7ef442a6/jiter-0.18.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:44d0d719cd7845e66b01a0cf346ec9882073e710a05e89a5b6d87be47a9dc69c", size = 277797, upload-time = "2026-10-23T13:51:09.324Z" }, | ||||
| 1053 | + { url = "https://files.pythonhosted.org/packages/ce/80/f0dadedaa9a109f31e8978c7db355593916968d5956f382188a3f8c0888c/jiter-0.18.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d1d2dc4d930ebc225f488b6b3790acebb2e51b3a165a4a42cc1d05bb927f83e", size = 300192, upload-time = "2026-10-23T13:51:10.551Z" }, | ||||
| 1054 | + { url = "https://files.pythonhosted.org/packages/93/51/e57dbc6c264ec6557a677189755fe4525d83439237418e3ae59771ac1cde/jiter-0.18.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebb0c346b6c5d9c033fe9e9b6ed6a1ff6dac0288fe681c3434dde6eb9c264fa1", size = 301141, upload-time = "2026-10-23T13:51:11.882Z" }, | ||||
| 1055 | +] | ||||
| 1056 | + | ||||
| 893 | [[package]] | 1057 | [[package]] | ||
| 894 | name = "korean-lunar-calendar" | 1058 | name = "korean-lunar-calendar" | ||
| 895 | version = "0.4.0" | 1059 | version = "0.4.0" | ||
@@ -1790,6 +1954,15 @@ wheels = [ | |||||
| 1790 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, | 1954 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, | ||
| 1791 | ] | 1955 | ] | ||
| 1792 | 1956 | ||||
| 1957 | +[[package]] | ||||
| 1958 | +name = "sniffio" | ||||
| 1959 | +version = "1.3.1" | ||||
| 1960 | +source = { registry = "https://pypi.org/simple" } | ||||
| 1961 | +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } | ||||
| 1962 | +wheels = [ | ||||
| 1963 | + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, | ||||
| 1964 | +] | ||||
| 1965 | + | ||||
| 1793 | [[package]] | 1966 | [[package]] | ||
| 1794 | name = "sortedcontainers" | 1967 | name = "sortedcontainers" | ||
| 1795 | version = "2.4.0" | 1968 | version = "2.4.0" | ||
@@ -1902,6 +2075,15 @@ wheels = [ | |||||
| 1902 | { url = "https://files.pythonhosted.org/packages/db/17/4c8beb6c8c4176c6bf143bfd7e1e4dd6719b00ced90738c7ac471b71c1df/toolz-1.2.0-py3-none-any.whl", hash = "sha256:890f820b1cb8152785aaf9386d8707770110809035800985ca65cb24ce1120ef", size = 60789, upload-time = "2026-10-07T04:16:24.173Z" }, | 2075 | { url = "https://files.pythonhosted.org/packages/db/17/4c8beb6c8c4176c6bf143bfd7e1e4dd6719b00ced90738c7ac471b71c1df/toolz-1.2.0-py3-none-any.whl", hash = "sha256:890f820b1cb8152785aaf9386d8707770110809035800985ca65cb24ce1120ef", size = 60789, upload-time = "2026-10-07T04:16:24.173Z" }, | ||
| 1903 | ] | 2076 | ] | ||
| 1904 | 2077 | ||||
| 2078 | +[[package]] | ||||
| 2079 | +name = "truststore" | ||||
| 2080 | +version = "0.10.5" | ||||
| 2081 | +source = { registry = "https://pypi.org/simple" } | ||||
| 2082 | +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } | ||||
| 2083 | +wheels = [ | ||||
| 2084 | + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, | ||||
| 2085 | +] | ||||
| 2086 | + | ||||
| 1905 | [[package]] | 2087 | [[package]] | ||
| 1906 | name = "typer" | 2088 | name = "typer" | ||
| 1907 | version = "0.27.3" | 2089 | version = "0.27.3" | ||
| 1908 | 2090 | ||||