diff --git a/README.md b/README.md index 0abb61a..c45b222 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ Recurring research, cron jobs, and webhook delivery. | --- | --- | --- | --- | --- | | [**Daily Insights**](typescript-recipes/parallel-daily-insights) | Cron-triggered daily research feed — runs Tasks on a schedule, persists to KV, publishes a public data feed. Includes a `SPEC.md` showing the task spec used. | `Task` `Webhooks` `Cron` | Cloudflare Workers · KV | – | | [**Vendor Intelligence**](typescript-recipes/parallel-vendor-intelligence) | Researches vendor risk, watches the structured report for changes, and runs follow-up research only when a change crosses the review threshold. | `Task` `Deep Research` `Monitors` | TypeScript · Local scripts | – | +| [**Investor Signals + Sales Enrichment**](python-recipes/parallel-investor-signals) | Track VC funds *you* choose for new AI-native rounds — one daily Monitor per fund, each detection chain-verified by a follow-up Task, scored, CRM-checked, and posted to Slack — plus a cited company-enrichment app. Bring your own watchlist and CRM; includes an `AGENTS.md` one-command setup. | `Task` `Monitors` `Webhooks` `Cron` | Python · FastAPI · React · Vercel | – | ### Deep Research & Notebooks diff --git a/python-recipes/parallel-investor-signals/.env.example b/python-recipes/parallel-investor-signals/.env.example new file mode 100644 index 0000000..1a9019c --- /dev/null +++ b/python-recipes/parallel-investor-signals/.env.example @@ -0,0 +1,47 @@ +# ── Required ──────────────────────────────────────────────────────────────── +# Parallel API key (platform.parallel.ai). Server-side only — never exposed +# to the browser; the frontend talks exclusively to your own /api routes. +PARALLEL_API_KEY="" + +# Shared passphrase for the app's access gate. The gate is CLOSED until this +# is set — there is no default. Rotate any time; stale sessions just re-gate. +DEMO_PASSWORD="" + +# ── Watchlist (optional override) ─────────────────────────────────────────── +# The funds you track normally live in monitor/investors.json (copy it from +# monitor/investors.example.json). Set this to a comma-separated list to +# override the file — handy for CI or one-off runs. Leave blank to use the file. +INVESTORS="" + +# ── Slack delivery (optional — pipeline runs fine without it) ─────────────── +# Incoming webhook for the signals channel. Unset = Slack quietly disabled. +SLACK_WEBHOOK_URL="" + +# ── Deployment (optional) ─────────────────────────────────────────────────── +# Public URL of the deployed app; used for "Enrich →" deep links in Slack. +APP_URL="" + +# Your Parallel ACCOUNT webhook secret (Parallel → Settings → Webhooks, +# "whsec_..."). The /api/monitor/webhook receiver verifies the Standard Webhooks +# signature Parallel adds to every delivery against this. FAILS CLOSED: if unset, +# the receiver rejects everything (it never runs unsigned). Not a URL query key. +# register the endpoint with: python monitor/monitors.py set-webhook https:// +WEBHOOK_SECRET="" + +# Auth for the weekly-digest cron. REQUIRED to enable the digest route: Vercel +# Cron sends "Authorization: Bearer " automatically when it's set. If unset +# the /api/signals/weekly-digest route is DISABLED (503), never public. +CRON_SECRET="" + +# ── CRM integration (optional) ────────────────────────────────────────────── +# The pipeline can check each signal against your CRM ("already in pipeline? +# active deals? who owns it?"). It's pluggable — Attio ships as the reference +# adapter. Pick the provider (default: attio) and set that provider's key. +# Add your own CRM by implementing one adapter file — see project/backend/crm.py +# and AGENTS.md. With no key set, signals fall back to the local known-companies +# label and everything else runs unchanged. +CRM_PROVIDER="attio" + +# Attio (the reference adapter): a Bearer token with record read scopes. +# Enables live checks (record + active deals + account owner + deep link). +ATTIO_API_KEY="" diff --git a/python-recipes/parallel-investor-signals/.gitignore b/python-recipes/parallel-investor-signals/.gitignore new file mode 100644 index 0000000..b9a3ccb --- /dev/null +++ b/python-recipes/parallel-investor-signals/.gitignore @@ -0,0 +1,238 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +/lib/ +/lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Backend venv +backend/.venv/ + +# Your GTM data — never commit (bring your own CSV export here) +data/ + +# Your watchlist — your target funds are your strategy, keep them private +monitor/investors.json + +# Monitor generated artifacts (ids, seen-event state, detected signals) +monitor/state.json +monitor/signals.json +monitor/monitors.json + +.DS_Store + +# Portfolio dedupe lists are derived from your own CRM export — never commit +monitor/portfolio_names.json +project/backend/portfolio_names.json diff --git a/python-recipes/parallel-investor-signals/AGENTS.md b/python-recipes/parallel-investor-signals/AGENTS.md new file mode 100644 index 0000000..41b1b61 --- /dev/null +++ b/python-recipes/parallel-investor-signals/AGENTS.md @@ -0,0 +1,188 @@ +# AGENTS.md: onboarding for a coding agent + +> **How to use this file.** Paste its contents (or point your agent at it) from a fresh +> clone of this recipe. It tells the agent what the project is, the invariants it must not +> break, and the exact sequence to take it from clone → running, including wiring up the +> user's own CRM. It's written to be executed, not just read. + +--- + +## Your task, in one line + +Get **Investor Signals + Sales Enrichment** running for the user: install everything, +collect the few things you can't invent (their Parallel API key, their fund watchlist, and +which CRM they use), launch the app, wire up their CRM, and, if they want it, create the +investor monitors and run the first sweep. Follow the steps in order; pause only for the +inputs marked **ASK THE USER**. + +## What this project is + +Two workflows on the Parallel **Task** and **Monitor** APIs, sharing one Python core: + +1. **Investor Signals pipeline** (`monitor/`), one daily Parallel Monitor per fund on the + user's watchlist detects new AI-native funding rounds (seed–Series B); each detection is + verified by a chained follow-up Task (`previous_interaction_id`), scored by a priority + policy, optionally checked against the user's CRM, and delivered to Slack (weekly digest + or real-time webhook). +2. **Sales Enrichment app** (`project/`), a React + FastAPI app. Type a company → two + concurrent Task runs (account + contacts) merge into one cited `ResearchBrief`. Bulk CSV + mode and an ad-hoc "ask bar" too. + +**Stack:** FastAPI (Python ≥ 3.12) backend, Vite + React + TypeScript frontend, deployable +to Vercel as one serverless function + static build. `parallel-web` SDK. + +## Map of the code + +| Path | What it is | +|---|---| +| `project/backend/parallel_client.py` | ★ The **only** file that reads `PARALLEL_API_KEY` and calls the Task API. | +| `project/backend/investor_core.py` | ★ Shared core: qualification schema, priority policy, Slack Block Kit formatting. One source of truth for the CLI, the app, and the webhook. | +| `project/backend/crm.py` | ★ The **CRM adapter seam**: selects a provider and normalizes its result. This is where you plug in the user's CRM. | +| `project/backend/attio_client.py` | The reference CRM adapter (Attio). Copy it to add another CRM. | +| `project/backend/main.py` | FastAPI routes, the access gate, the webhook receiver, the cron endpoint. | +| `project/backend/signals_service.py` | Signals surfaces + the weekly-digest job. | +| `project/src/` | The React app. `types.ts` mirrors the backend `ResearchBrief` exactly. | +| `monitor/config.py` | Loads the watchlist + holds the monitor queries/schemas/processors. | +| `monitor/investors.example.json` | Sample watchlist. Copy to `investors.json` (gitignored). | +| `monitor/{sweep,monitors,check,slack_notify,build_portfolio}.py` | The pipeline CLI. | +| `tests/` | pytest suite (credibility gate, priority policy, routes, Slack blocks). | + +## Invariants: do not break these + +1. **The API key stays server-side.** Only `parallel_client.py` (and the async webhook in + `main.py`) touch `PARALLEL_API_KEY`. The browser talks exclusively to `/api/*`. Never + put the key in frontend code, logs, or anything committed. +2. **Never fabricate a value.** The credibility rule is load-bearing: any field without a + supporting citation is returned as `null`. Don't "helpfully" fill blanks. +3. **Don't touch the output JSON schemas or the `field-basis` beta header** in + `parallel_client.py` without updating `types.ts` and `to_research_brief()` in lockstep, the field names map one-to-one to the UI. +4. **Secrets and private data never get committed.** `.env`, `monitor/investors.json`, + `data/`, and the generated `monitor/*.json` (`portfolio_names.json`, `monitors.json`, + `signals.json`, `state.json`) are all gitignored. Keep them that way. Use the + `.example` files for anything you commit. +5. **The watchlist and the CRM belong to the user.** There is no default fund list, and no + CRM is assumed, ask. + +## Setup sequence + +Run these from the recipe root (`python-recipes/parallel-investor-signals`). + +### Step 1: install (safe to run unattended) + +```bash +make setup # creates the venv, installs backend + dev + frontend deps, scaffolds .env +``` + +### Step 2: collect what you can't invent + +**ASK THE USER** for: + +- **Their Parallel API key** (from [platform.parallel.ai](https://platform.parallel.ai)). +- **The VC funds they want to track**, by press name (e.g. "Sequoia Capital", + "Andreessen Horowitz"). If they don't have a list yet, use the example list and tell them + they can edit `monitor/investors.json` later. +- **Which CRM they use** (Attio, HubSpot, Salesforce, Pipedrive, Affinity, none, …). You'll + wire it up in Step 4. + +Write the key and watchlist (do **not** echo the key back or commit `.env`): + +```bash +# .env: set at least these two +# PARALLEL_API_KEY= +# DEMO_PASSWORD= + +cp monitor/investors.example.json monitor/investors.json +# then edit monitor/investors.json → the "investors" array = their funds +``` + +Confirm the backend can see the key without printing it: + +```bash +source project/backend/.venv/bin/activate +python -c "import os,dotenv; dotenv.load_dotenv('.env'); print('key_loaded:', bool(os.environ.get('PARALLEL_API_KEY')))" +``` + +### Step 3: run the enrichment app + +Launch both processes (background them or use two terminals): + +```bash +make backend # FastAPI on :8000 +make frontend # Vite on :5173 +``` + +Tell the user to open **http://localhost:5173**, unlock with their `DEMO_PASSWORD`, and try +`ramp.com`. Backend health (no secret leaked): `curl -s localhost:8000/api/health`. + +### Step 4: wire up their CRM + +The CRM is **pluggable**. The signals pipeline asks one question about each company, *already in our CRM? active deals? who owns it?*, through the small contract in +`project/backend/crm.py`. Your job: make that contract answer against the user's CRM. + +- **If they use Attio** → it's the reference adapter and already implemented. Just set + `CRM_PROVIDER="attio"` and `ATTIO_API_KEY=...` in `.env`. Done. +- **If they use another CRM** → build an adapter: + 1. **Find the API docs.** Look up that CRM's REST API, the endpoints for *search/query a + company by domain or name*, *list a company's deals/opportunities*, and *get the record + owner*, plus how auth works (usually a Bearer token or OAuth). Use web search / fetch + the official developer docs; confirm the exact request/response shapes before coding. + 2. **Implement the adapter.** Copy `project/backend/attio_client.py` to + `project/backend/_client.py` and implement the three names the contract requires + (read the docstring at the top of `crm.py`): + - `NAME: str`, e.g. `"HubSpot"` + - `def enabled() -> bool`, is its API key configured? + - `def check_pipeline(domain, company) -> dict | None`, return + `{in_crm, record_id, deal_count, owner, url}`, or `None` when unavailable. + 3. **Register it** in `crm.py`'s `_PROVIDERS` dict, add the key var to `.env.example`, and + set `CRM_PROVIDER=` + the API key in `.env`. + 4. **Verify** against a company you know is in their CRM: + ```bash + python -c "from project.backend import crm; print(crm.check_pipeline('ramp.com','Ramp'))" + ``` + (Run from the recipe root with the venv active; expect the normalized dict or `None`.) +- **If they use no CRM** → skip it. Signals fall back to the local known-companies label and + everything else runs unchanged. + +Keep the credibility and secrets invariants: the CRM key is server-side only, and never +invent fields the CRM API didn't return. + +### Step 5: the investor-signals pipeline (only if the user wants it) + +Creating monitors **spends Parallel credits and creates cloud resources**: confirm with the +user before running `monitors.py create`. + +```bash +source project/backend/.venv/bin/activate +python monitor/sweep.py # once: 60-day backfill per fund → signals.json +python monitor/monitors.py create # once: one daily monitor per fund (cloud resource) +python monitor/check.py # drain + verify new events (run on any cadence) +python monitor/slack_notify.py --preview # dry-run the Slack format +``` + +To clean up the cloud monitors later: `python monitor/monitors.py cancel`. + +### Step 6: verify your work + +```bash +make test # backend pytest + frontend vitest, must pass +make lint # ruff +``` + +If you added a CRM adapter, add a small unit test for its `check_pipeline` result mapping +(stub the HTTP call, no live CRM in tests, mirroring `tests/conftest.py`). + +## Deploying (only when asked) + +Vercel: static Vite build + FastAPI as one serverless function (`project/api/index.py`, +`project/vercel.json`). From `project/`: `vercel deploy --prod --yes`, then +`vercel env add production` for each required variable (including `CRM_PROVIDER` and +your CRM's key). See the README's [Deployment](README.md#deployment-vercel) section. For +real-time Slack push, register the webhook with +`python monitor/monitors.py set-webhook https://.vercel.app`. + +## When you're done + +Report to the user: what's running and on which ports, which CRM you wired up (and whether +its live check succeeded), whether monitors were created (that they cost credits + how to +cancel), whether tests passed, and the one or two things still in their hands. Never claim +the pipeline is "live" if you only ran setup, say exactly what you did. diff --git a/python-recipes/parallel-investor-signals/LICENSE b/python-recipes/parallel-investor-signals/LICENSE new file mode 100644 index 0000000..1a2d13e --- /dev/null +++ b/python-recipes/parallel-investor-signals/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 James Liounis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python-recipes/parallel-investor-signals/Makefile b/python-recipes/parallel-investor-signals/Makefile new file mode 100644 index 0000000..e41813a --- /dev/null +++ b/python-recipes/parallel-investor-signals/Makefile @@ -0,0 +1,43 @@ +# Developer ergonomics. Every target assumes repo root. +VENV := project/backend/.venv +PY := $(VENV)/bin/python +PIP := $(VENV)/bin/pip + +.PHONY: help setup dev backend frontend test test-backend test-frontend lint build clean + +help: ## List targets + @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + +setup: ## One-time: venv + backend deps + dev tools + frontend deps + python3 -m venv $(VENV) + $(PIP) install -q -r project/backend/requirements.txt -r requirements-dev.txt + cd project && npm install + @test -f .env || cp .env.example .env + @echo "→ fill in .env (PARALLEL_API_KEY at minimum), then: make dev" + +backend: ## Run the FastAPI backend on :8000 + cd project && $(abspath $(PY)) -m uvicorn backend.main:app --reload --port 8000 + +frontend: ## Run the Vite dev server on :5173 + cd project && npm run dev + +dev: ## Print the two commands to run (separate terminals) + @echo "terminal 1: make backend" + @echo "terminal 2: make frontend" + +test: test-backend test-frontend ## All tests + +test-backend: ## Backend unit + integration tests + $(PY) -m pytest + +test-frontend: ## Frontend lib tests + cd project && npm run test -- --run + +lint: ## Ruff over Python + $(VENV)/bin/ruff check . + +build: ## Production frontend build (also typechecks) + cd project && npm run build + +clean: ## Remove generated artifacts + rm -rf project/dist project/node_modules $(VENV) diff --git a/python-recipes/parallel-investor-signals/README.md b/python-recipes/parallel-investor-signals/README.md new file mode 100644 index 0000000..3203af3 --- /dev/null +++ b/python-recipes/parallel-investor-signals/README.md @@ -0,0 +1,229 @@ +# Investor Signals + Sales Enrichment + +[![License: MIT](https://img.shields.io/badge/License-MIT-black.svg)](LICENSE) + +**A GTM intelligence stack built on the [Parallel](https://parallel.ai) Task & Monitor APIs**: two production-grade workflows that replace stale data-vendor subscriptions with live, cited web research: + +1. **Investor Signals pipeline**: you pick the VC funds you want to track. Daily Parallel Monitors watch each fund for new AI-native investments (seed–Series B), verify every detection with a chained research task, check your CRM, score priority, and deliver a weekly Slack digest with warm-intro paths. +2. **Sales Enrichment app**: type a company, get a fully-sourced account brief in ~1 minute: firmographics, funding, tech stack, buying signals, decision-maker contacts. Every field traceable to the exact web page it came from. Bulk CSV mode included. + +> **You bring the watchlist.** Nothing about which funds to track is hardcoded. You list your own in `monitor/investors.json` (gitignored, never committed). See [Choose the funds you track](#1-choose-the-funds-you-track). + +![Results view: every field cited, with confidence ratings](docs/results-light.png) + +## Why this exists + +Data vendors ship you a **snapshot of a database**. This stack runs **live web research at request time**: + +- **Fresh, not stale**: funding rounds and hiring surges from this week, not last year's crawl. +- **Grounded, not asserted**: every value carries citations with source excerpts. A value with no supporting citation renders blank: the backend **never fabricates** (enforced server-side, covered by tests). +- **Honest about uncertainty**: per-field confidence ratings; contact emails are either citation-backed or clearly labeled as inferred patterns, never "verified." + +## What it shows + +- **Task API with structured output + research basis**: per-field citations, excerpts, and confidence on every enriched value. +- **Monitor API (`event_stream`)**: one daily monitor per fund, cheap wide detection with structured, cited output. +- **The monitor → task chain**: each detected event is passed as `previous_interaction_id` to a follow-up Task that verifies it with fresh citations. Detection is tuned for recall; verification for precision. +- **Webhooks + Cron**: real-time push via a serverless receiver, or a scheduled weekly digest. +- A credibility rule, priority policy, and Slack formatting that are all **one source of truth**, shared across the CLI pipeline, the web app, and the serverless webhook. + +--- + +## Setup for humans + +Everything you need to run this locally, step by step. If you'd rather have a coding agent do it for you, hand it [`AGENTS.md`](AGENTS.md), a single paste-in prompt that sets the whole thing up (see [One-command agent setup](#one-command-agent-setup)). + +### Prerequisites + +- **Node ≥ 20** and **Python ≥ 3.12** +- A **Parallel API key**: create one at [platform.parallel.ai](https://platform.parallel.ai) +- *(optional)* a Slack incoming webhook, and an [Attio](https://attio.com) API key, for delivery + live CRM checks + +### 0. Clone and install + +```bash +git clone https://github.com/parallel-web/parallel-cookbook.git +cd parallel-cookbook/python-recipes/parallel-investor-signals + +make setup # venv + backend deps + dev tools + frontend deps + scaffolds .env +``` + +`make setup` copies `.env.example` to `.env`. Open `.env` and set at least: + +```dotenv +PARALLEL_API_KEY=your_key_here # server-side only; never exposed to the browser +DEMO_PASSWORD=pick-any-passphrase # the app's access gate is CLOSED until this is set +``` + +Every variable is documented in [`.env.example`](.env.example). All optional integrations (Slack, Attio, webhook push, cron) degrade gracefully when unset. + +### 1. Choose the funds you track + +The watchlist is **yours**: the repo ships no baked-in list. Copy the example and edit it: + +```bash +cp monitor/investors.example.json monitor/investors.json +``` + +```jsonc +// monitor/investors.json (gitignored: your target list stays private) +{ + "investors": [ + "Sequoia Capital", + "Andreessen Horowitz", + "Your Fund of Interest" + ] +} +``` + +List funds by the name they go by in the press. `monitor/investors.json` is gitignored, so your target list never lands in git. (You can also set an `INVESTORS="Fund A,Fund B"` env var, which overrides the file, handy in CI.) + +### 2. Run the enrichment app + +Two processes, two terminals: + +```bash +make backend # terminal 1 → FastAPI on http://localhost:8000 +make frontend # terminal 2 → Vite on http://localhost:5173 +``` + +Open **http://localhost:5173**, unlock with your `DEMO_PASSWORD`, and enrich a company (try `ramp.com`). Click any source marker to see the exact excerpt behind a claim. See [`project/README.md`](project/README.md) for the full app tour. + +### 3. Run the investor-signals pipeline + +```bash +source project/backend/.venv/bin/activate + +python monitor/sweep.py # once: 60-day backfill (monitors can't do history) +python monitor/monitors.py create # once: one daily Parallel monitor per fund +python monitor/check.py # any cadence: drain + verify new events → signals.json +python monitor/slack_notify.py --preview # inspect the Slack format, dry-run +``` + +`check.py` is the recurring entry point: run it manually, via cron, or wire it to a scheduler. Full pipeline details, design decisions, and Slack conventions are in [`monitor/README-monitor.md`](monitor/README-monitor.md). + +**Optional, label companies you already know.** To tag signals as "known" vs "net-new," derive a names-only list from any CRM/portfolio CSV export: + +```bash +python monitor/build_portfolio.py data/your-companies.csv --name-col Company +``` + +The raw CSV (`data/`) and the derived `portfolio_names.json` are both gitignored; only a fictional example fixture is committed. + +### 4. Run the tests + +```bash +make test # backend pytest + frontend vitest +make lint # ruff over Python +``` + +--- + +## Architecture + +``` + ┌─ Sales Enrichment (on demand) ─────────────────────────┐ +Browser ── /api/enrich ──▶│ FastAPI ─┬▶ Task run: ACCOUNT (firmo · funding · tech)│ + ▲ │ └▶ Task run: CONTACTS (concurrent) │ + └── cited ResearchBrief◀───────────── merged, credibility-rule enforced ────────┘ + + ┌─ Investor Signals (continuous) ────────────────────────┐ +Parallel Monitors (cloud) │ 1 event_stream monitor per fund on your watchlist, daily│ + │ events └────────────────────────────────────────────────────────┘ + ▼ +/api/signals/weekly-digest (Vercel Cron, Mondays) /api/monitor/webhook (real-time, optional) + │ strict 7-day gates → chained verification (previous_interaction_id) + │ → CRM check (Attio) → priority score (stage/raise/fit) + ▼ + ONE Slack message: every qualifying round, with sources + intro path + CRM link +``` + +Key design decisions, all covered by the test suite: + +| Rule | Where | +|---|---| +| **Credibility rule**: no citation → `null`, never a fabricated value | `backend/parallel_client.py` | +| **Detection ≠ qualification**: monitors are recall-tuned; every event passes a chained verification task before it becomes a signal | `backend/investor_core.py` | +| **Strict date windows**: unparseable dates are excluded, never assumed fresh | `backend/signals_service.py` | +| **Priority policy**: Seed/Series A weighted over Series B ("rip-and-replace" territory), + raise size + product-fit rating | `investor_core.priority_for` | +| **Server-side access gate**: every `/api` route requires a passphrase; closed until `DEMO_PASSWORD` is set | `backend/main.py` | + +## Repository map + +``` +├── project/ # the web app +│ ├── backend/ # FastAPI (Python) +│ │ ├── parallel_client.py # ★ the only file that talks to Parallel (Task API) +│ │ ├── investor_core.py # ★ signals pipeline core: schema, priority, Slack blocks +│ │ ├── signals_service.py # signals surfaces + weekly digest job +│ │ ├── attio_client.py # CRM pipeline checks (optional) +│ │ └── main.py # routes, access gate, webhook receiver, cron endpoint +│ └── src/ # React + TypeScript + Tailwind +├── monitor/ # the pipeline CLI (watchlist, monitors, sweep, drain, notify) +│ ├── investors.example.json # sample watchlist: copy to investors.json and make it yours +│ └── config.py # watchlist loader + query/schema/processor tuning surface +├── tests/ # backend unit + integration tests (pytest) +├── AGENTS.md # paste-in onboarding prompt for a coding agent +├── Makefile # make setup / dev / test / lint / build +└── .env.example # every configuration variable, documented +``` + +## Configuration + +All via environment (see [`.env.example`](.env.example)): + +| Variable | Required | Purpose | +|---|---|---| +| `PARALLEL_API_KEY` | ✅ | Parallel API access (server-side only) | +| `DEMO_PASSWORD` | ✅ | Access-gate passphrase; the API is closed without it | +| `INVESTORS` | – | Comma-separated watchlist override (otherwise read from `investors.json`) | +| `SLACK_WEBHOOK_URL` | for Slack | Incoming webhook; unset = delivery quietly disabled | +| `APP_URL` | for Slack links | Deployed app URL for "Enrich →" deep links | +| `CRON_SECRET` | required for digest | Bearer auth for the weekly-digest cron; the route is **disabled (503)** until this is set (never public) | +| `WEBHOOK_SECRET` | required for webhook | Your Parallel **account webhook secret** (`whsec_…`); the receiver verifies the Standard Webhooks signature and **fails closed** without it | +| `ATTIO_API_KEY` | for CRM checks | Live CRM presence / associated-deal / owner lookups + record links (reference adapter) | + +Beyond the watchlist, the two other tuning surfaces are the **priority thresholds** (`investor_core.STAGE_WEIGHT`, `priority_for`) and the **fit rubric**: the 1–10 prospect-scoring prompt (`investor_core.FIT_RUBRIC`). Adapt all three to your product and funds. + +## Deployment (Vercel) + +The app deploys as a static Vite build plus the FastAPI app running as a single Python serverless function (`project/api/index.py` re-exports it; `project/vercel.json` holds the routing, function limits, and cron schedule). + +```bash +cd project +vercel deploy --prod --yes +# then add each required var for production, e.g.: +vercel env add PARALLEL_API_KEY production +vercel env add DEMO_PASSWORD production +``` + +To turn on real-time Slack pings from the deployed receiver (no cron, no polling): + +```bash +# Set WEBHOOK_SECRET on the deployment to your Parallel account webhook secret +# (whsec_…, from Parallel → Settings → Webhooks), plus SLACK_WEBHOOK_URL, then: +python monitor/monitors.py set-webhook https://.vercel.app +``` + +The receiver verifies the Standard Webhooks signature Parallel adds to every delivery and rejects anything unsigned or unverifiable, so no secret ever rides in the URL. + +Notes: +- `maxDuration` is 300s. "Fast" lookups (~60–80s) fit comfortably; the deeper processor tier can exceed it. +- **Cron time is UTC.** `vercel.json` runs the digest at `0 15 * * 1` (15:00 UTC Monday). Vercel evaluates cron in UTC and does not track daylight saving, so pick the UTC hour you want rather than assuming a fixed Pacific time. The route also requires `CRON_SECRET`. +- **Bulk mode is local/self-host only.** Job state is in-memory and the work runs after the response returns, neither of which survives serverless instance hops, so the deployed backend refuses bulk (501) and the Bulk tab is hidden on deploys by default. Run it against a local or persistent backend (set `VITE_ENABLE_BULK=1` to re-expose the tab there). +- The webhook receiver runs research inline for simplicity; a production receiver should ACK 2xx immediately and process on a durable queue, deduping by `webhook-id`. +- The Signals UI tab is dev-only by default; set `VITE_SHOW_SIGNALS=1` at build time to expose it on a deploy. Slack is the intended production delivery channel. + +## One-command agent setup + +If you use a coding agent (Claude Code, Cursor, etc.), [`AGENTS.md`](AGENTS.md) is a self-contained onboarding prompt. Paste it into the agent from a fresh clone and it will: install everything, scaffold `.env`, help you set your watchlist, run the app, and, with your say-so, create the monitors and run the pipeline. It's the fastest path from clone to running. + +## Documentation + +- [`project/README.md`](project/README.md): web app details, project structure, walkthrough +- [`monitor/README-monitor.md`](monitor/README-monitor.md): pipeline architecture, design decisions, Slack conventions +- [`AGENTS.md`](AGENTS.md): paste-in agent onboarding + +## License + +[MIT](LICENSE) diff --git a/python-recipes/parallel-investor-signals/docs/empty-state.png b/python-recipes/parallel-investor-signals/docs/empty-state.png new file mode 100644 index 0000000..decc667 Binary files /dev/null and b/python-recipes/parallel-investor-signals/docs/empty-state.png differ diff --git a/python-recipes/parallel-investor-signals/docs/results-dark.png b/python-recipes/parallel-investor-signals/docs/results-dark.png new file mode 100644 index 0000000..a6224d9 Binary files /dev/null and b/python-recipes/parallel-investor-signals/docs/results-dark.png differ diff --git a/python-recipes/parallel-investor-signals/docs/results-light.png b/python-recipes/parallel-investor-signals/docs/results-light.png new file mode 100644 index 0000000..3baab10 Binary files /dev/null and b/python-recipes/parallel-investor-signals/docs/results-light.png differ diff --git a/python-recipes/parallel-investor-signals/monitor/README-monitor.md b/python-recipes/parallel-investor-signals/monitor/README-monitor.md new file mode 100644 index 0000000..957eee2 --- /dev/null +++ b/python-recipes/parallel-investor-signals/monitor/README-monitor.md @@ -0,0 +1,89 @@ +# Investor Signals: the monitoring pipeline + +**Goal:** flag every time a fund on *your* watchlist backs an AI-native company at seed–Series B, so your team can get ahead of the round. ([Full project context](../README.md).) + +Set your watchlist first, copy `investors.example.json` to `investors.json` and list the funds you care about (see the [top-level README](../README.md#1-choose-the-funds-you-track)). + +This pipeline uses the two Parallel APIs **together, not either alone**: + +| Layer | API | Why | +|---|---|---| +| **Bootstrap** (history) | Task API | Monitors only track forward from creation, the first exhaustive search of the last 60 days is a Task job | +| **Detect** (continuous) | Monitor API | One `event_stream` monitor per fund, daily, `lite`, cheap wide detection with structured output + citations | +| **Verify** (per event) | Task API, chained | Each event's `event_id` is passed as `previous_interaction_id` to a follow-up Task that confirms fund/stage/AI-nativeness with citations, the precision gate that prevents alert fatigue | + +## Files + +``` +monitor/ +├── config.py # watchlist loader, queries, schemas, processors, the tuning surface +├── investors.example.json # sample watchlist, copy to investors.json (gitignored) and make it yours +├── common.py # Parallel client, portco dedupe, signal store, task runner +├── sweep.py # bootstrap: exhaustive Task sweep (last 60 days per fund) +├── monitors.py # create | list | trigger | cancel the per-fund monitors +├── check.py # drain new events → chained verification → signals.json +├── build_portfolio.py # derive the names-only known-companies list from any CSV export +├── portfolio_names.example.json # fictional fixture (your real list is generated + gitignored) +└── signals.json # OUTPUT: qualified, cited signals (gitignored, generated) +``` + +`investors.json`, `data/` (raw CRM exports), and the derived `portfolio_names.json` are all **gitignored**: only fictional example fixtures are committed. Your target funds and CRM are yours; nothing about them ships in git. + +## Run it + +```bash +source project/backend/.venv/bin/activate +python monitor/sweep.py # once: backfill history, seed signals.json +python monitor/monitors.py create # once: start one daily monitor per fund +python monitor/check.py # any cadence: drain + verify new events +python monitor/monitors.py trigger # optional: force a run now (then check.py) +``` + +`check.py` is the recurring entry point, run it manually, via cron, or wire it to a scheduler. Each signal in `signals.json` carries: company, stage, amount, date, investors, AI-native one-liner, **source URLs**, whether it's already on your known-companies list, and the run/event IDs for provenance. + +## Design decisions + +- **One monitor per fund**, not one broad query, scoped intent-heavy queries perform better, `metadata.fund` routes events, and cadence/processor tune per fund. +- **Known companies are labeled, never suppressed**: a new round in a company you already know is still a signal; only live pipeline status (e.g. an Attio lookup) should suppress it. +- **Polling or webhooks**: `check.py` drains events on demand (zero public-endpoint complexity), or point the monitors at the deployed `/api/monitor/webhook` receiver for real-time push. Same verification chain either way. +- **Verification defaults on** (`check.py --raw` to skip): detection is optimized for recall, the chained Task run is optimized for precision. Alert quality is the product. + +## Slack delivery + +Delivery is a standard Slack incoming webhook + [Block Kit](https://api.slack.com/block-kit). `investor_core.post_to_slack` quietly no-ops when `SLACK_WEBHOOK_URL` is unset, so the pipeline runs fine without Slack, nothing breaks, signals still land in `signals.json`. + +House style, all in `build_signal_blocks`: + +- Emoji header by priority, 🚨 high / 🔔 medium / 🗞 digest +- A `•`-separated context line, then one labeled `section` per field +- A warm-intro-path section (intro via the investing partner) +- `📎 Sources:` numbered links, capped at 5 + +There are two delivery paths: + +- **Weekly digest**: one message on a schedule (Vercel Cron hits `/api/signals/weekly-digest`), rolling up the week's qualifying rounds. The route requires `CRON_SECRET` (Bearer auth) and is disabled without it; the cron time is UTC (see the top-level README's deployment notes on UTC/DST). +- **Real-time webhook**: Parallel calls `/api/monitor/webhook` on `monitor.event.detected`; the FastAPI receiver verifies the signature → parses the event → runs the chained verification Task → CRM check → priority gate → Slack. `metadata.fund` routes the event; digest-priority events don't ping. It dedupes retries by `webhook-id`. + +Enable end-to-end push (no cron, no polling): + +```bash +# 1. Set WEBHOOK_SECRET to your Parallel account webhook secret (whsec_…, from +# Parallel → Settings → Webhooks) plus SLACK_WEBHOOK_URL, on .env + your host. +# 2. Point the monitors at the deployed receiver: +python monitor/monitors.py set-webhook https://.vercel.app +# From then on: monitor fires → Parallel signs the delivery → the receiver +# verifies the signature → chained Task → Slack ping. +``` + +The receiver **fails closed**: it rejects any delivery whose Standard Webhooks signature it can't verify with `WEBHOOK_SECRET`, so it never processes an unsigned request or spends credits on a forged one. + +Test formatting anytime: `python monitor/slack_notify.py --preview` (dry-run) / `--send`. + +## Extending it + +Natural next steps, none required to run the pipeline: + +1. **Live CRM check**: replace the names-list label with a real "in pipeline?" lookup (an `ATTIO_API_KEY` wires this up today) and suppress or re-route accordingly. +2. **Full enrichment per signal**: auto-run the app's `ResearchBrief` for each new company so contacts + buying signals are ready before anyone opens the app. +3. **Feedback loop**: log ✅/❌ Slack reactions against signals to tune the qualification prompt. +4. **Curated key sources**: additional monitors with `source_policy.include_domains` for authority-weighted detection. diff --git a/python-recipes/parallel-investor-signals/monitor/build_portfolio.py b/python-recipes/parallel-investor-signals/monitor/build_portfolio.py new file mode 100644 index 0000000..6ea2c87 --- /dev/null +++ b/python-recipes/parallel-investor-signals/monitor/build_portfolio.py @@ -0,0 +1,71 @@ +""" +build_portfolio.py — regenerate the known-portfolio dedupe list from your CRM +export, so signals can be labeled "already known" vs "net-new". + +Input: a CSV with (at least) a company-name column — e.g. an export of your +investors' portfolio companies. Internal columns (notes, ratings, pipeline +state) are ignored on purpose: ONLY the normalized name and an optional +investor column make it into the output, which is safe to keep locally but is +still gitignored by default (see .gitignore). + +Usage (backend venv active): + python monitor/build_portfolio.py data/portcos.csv + python monitor/build_portfolio.py data/portcos.csv --name-col Company --investor-col Investor + +Writes monitor/portfolio_names.json and mirrors it to +project/backend/portfolio_names.json (the copy bundled into the deployed app +for serverless labeling). +""" + +from __future__ import annotations + +import argparse +import csv +import json +import sys +from pathlib import Path + +MONITOR_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(MONITOR_DIR.parent / "project")) + +from backend.investor_core import norm_company # noqa: E402 — single normalizer everywhere + + +def main() -> None: + ap = argparse.ArgumentParser(description="Derive the names-only portfolio list from a CSV.") + ap.add_argument("csv_path", help="CSV export containing your portfolio companies") + ap.add_argument("--name-col", default="Company", help="Column holding the company name") + ap.add_argument("--investor-col", default="Investor", help="Optional column holding the fund name") + args = ap.parse_args() + + with open(args.csv_path, newline="") as f: + rows = list(csv.DictReader(f)) + if not rows: + raise SystemExit("CSV appears to be empty.") + if args.name_col not in rows[0]: + raise SystemExit( + f"Column {args.name_col!r} not found. Available: {', '.join(rows[0].keys())}" + ) + + out = {} + for r in rows: + name = (r.get(args.name_col) or "").strip() + if not name: + continue + out[norm_company(name)] = { + "name": name, + "investor": (r.get(args.investor_col) or "").strip(), + } + + targets = [ + MONITOR_DIR / "portfolio_names.json", + MONITOR_DIR.parent / "project" / "backend" / "portfolio_names.json", + ] + payload = json.dumps(out, indent=1, sort_keys=True) + "\n" + for t in targets: + t.write_text(payload) + print(f"wrote {len(out)} companies -> {t.relative_to(MONITOR_DIR.parent)}") + + +if __name__ == "__main__": + main() diff --git a/python-recipes/parallel-investor-signals/monitor/check.py b/python-recipes/parallel-investor-signals/monitor/check.py new file mode 100644 index 0000000..ea6d0b3 --- /dev/null +++ b/python-recipes/parallel-investor-signals/monitor/check.py @@ -0,0 +1,181 @@ +""" +check.py — drain new monitor events and run the monitor→task chain. + +For every event we haven't seen: + 1. Parse the monitor's structured output (company, round, investors, …). + 2. VERIFY via a follow-up Task run, passing the event's `event_id` as + `previous_interaction_id` — the documented chain that carries the monitor + event's full research context into the Task and preserves provenance. + The verification confirms: fund participation, seed–Series B stage, and + AI-nativeness — with citations. Detection is cheap and wide; this gate is + what keeps the signal feed high-precision. + 3. Label known portcos (never suppress — a round in a known portco is still + news; pipeline-status suppression arrives with the CRM integration). + 4. Append to signals.json and print a digest. + +Run on any cadence (manually, cron, or after `monitors.py trigger`): + python monitor/check.py # verify with follow-up tasks (default) + python monitor/check.py --raw # skip verification (just drain + label) +""" + +from __future__ import annotations + +import json +import sys + +from common import ( + MONITORS_FILE, STATE_FILE, append_signals, client, known_portco, + load_portfolio, print_signal, read_json, run_structured_task, utcnow, + write_json, +) +from config import ROUND_SCHEMA, VERIFY_PROCESSOR, verify_input +from backend.investor_core import notify_signals_sync, qualify_signal, slack_enabled +from backend.crm import check_pipeline + +# Safety bound on cursor-following so a huge backlog can't loop unbounded. +_MAX_EVENT_PAGES = 50 + + +def events_for_monitor(c, monitor_id: str, per_page: int = 100) -> list: + """All events for a monitor, following next_cursor (the API pages results; + a single fixed page would silently drop events on a backlog).""" + out: list = [] + cursor = None + for _ in range(_MAX_EVENT_PAGES): + kwargs = {"limit": per_page} + if cursor: + kwargs["cursor"] = cursor + page = c.monitor.events(monitor_id, **kwargs) + out.extend(getattr(page, "events", None) or []) + cursor = getattr(page, "next_cursor", None) + if not cursor: + return out + print(f"[check] event page cap ({_MAX_EVENT_PAGES}) hit for {monitor_id}; some events may be omitted") + return out + + +def parse_event_content(event) -> dict: + """Monitor structured output arrives as a JSON string in output.content.""" + content = getattr(event.output, "content", None) if getattr(event, "output", None) else None + if isinstance(content, str): + try: + return json.loads(content) + except json.JSONDecodeError: + return {"summary": content} + return content or {} + + +def event_citations(event) -> list: + urls = [] + output = getattr(event, "output", None) + for fb in (getattr(output, "basis", None) or []): + for cit in (getattr(fb, "citations", None) or []): + u = getattr(cit, "url", None) + if u and u not in urls: + urls.append(u) + return urls + + +def verify_event(c, fund: str, detected: dict, event_id: str) -> list: + """The follow-up-task chain: monitor event -> verified, cited rounds.""" + res = run_structured_task( + c, + verify_input(detected), + ROUND_SCHEMA, + VERIFY_PROCESSOR, + previous_interaction_id=event_id, + ) + out = [] + for r in res["content"].get("rounds", []): + if str(r.get("is_ai_native", "")).lower() != "yes": + continue + out.append({**r, "sources": res["citations"], "verify_run_id": res["run_id"]}) + return out + + +def drain_events(c, portfolio: dict, monitors: dict, seen: set, *, raw: bool = False) -> list: + """Drain unseen events across all monitors into fully-qualified signals. + + THE ONE drain path — shared by this CLI and the web app's local refresh, so + they can't drift. Mutates `seen` with handled event ids. Each verified round + goes through `qualify_signal` (CRM check → known/net-new flag → pipeline label + → CRM link → priority), identical to the weekly digest and the webhook.""" + new_signals: list = [] + for fund, info in monitors.items(): + events = events_for_monitor(c, info["monitor_id"]) + fresh = [e for e in events if e.event_id not in seen] + print(f"[check] {fund}: {len(events)} events, {len(fresh)} new") + + for e in fresh: + seen.add(e.event_id) + detected = parse_event_content(e) + company = (detected.get("company_name") or "").strip() + if not company or company.upper() == "NA": + continue # not a specific funding event + + base = { + "fund_watched": fund, + "detected_via": "monitor", + "event_id": e.event_id, + "event_date": str(getattr(e, "event_date", "") or ""), + "detected_at": utcnow(), + } + + if raw: + new_signals.append({ + "company": company, + "round_stage": detected.get("round_stage"), + "amount": detected.get("amount"), + "announced_date": detected.get("announced_date"), + "investors": detected.get("investors"), + "summary": detected.get("summary"), + "known_portco": bool(known_portco(company, portfolio)), + "sources": event_citations(e), + **base, + }) + continue + + # Chained verification (previous_interaction_id = event_id), then the + # shared qualification (live CRM check + priority + label). + for r in verify_event(c, fund, detected, e.event_id): + match = check_pipeline(r.get("domain"), r.get("company", "")) + new_signals.append(qualify_signal(r, fund, portfolio, base, match)) + return new_signals + + +def main() -> None: + raw = "--raw" in sys.argv + c = client() + portfolio = load_portfolio() + monitors = read_json(MONITORS_FILE, {}) + if not monitors: + raise SystemExit("No monitors yet — run: python monitor/monitors.py create") + + state = read_json(STATE_FILE, {"seen_event_ids": []}) + seen = set(state["seen_event_ids"]) + + new_signals = drain_events(c, portfolio, monitors, seen, raw=raw) + + state["seen_event_ids"] = sorted(seen) + write_json(STATE_FILE, state) + added = append_signals(new_signals) + + print(f"\n=== CHECK DIGEST — {added} new signals ===") + for s in new_signals: + print_signal(s) + if not new_signals: + print(" (no new qualifying events)") + + # Slack delivery: high/medium ping individually, + # digest-priority signals roll into one message. Quiet no-op if + # SLACK_WEBHOOK_URL isn't set in the root .env. + if new_signals: + if slack_enabled(): + sent = notify_signals_sync(new_signals, "Investor signals — new events") + print(f"[slack] {sent} message(s) posted") + else: + print("[slack] SLACK_WEBHOOK_URL not set — skipped (add it to .env to enable pings)") + + +if __name__ == "__main__": + main() diff --git a/python-recipes/parallel-investor-signals/monitor/common.py b/python-recipes/parallel-investor-signals/monitor/common.py new file mode 100644 index 0000000..3a43d77 --- /dev/null +++ b/python-recipes/parallel-investor-signals/monitor/common.py @@ -0,0 +1,147 @@ +""" +common.py — shared plumbing for the monitoring pipeline: Parallel client, the +known-portco dedupe list, signal storage, and a small structured-Task runner. + +Run everything with the backend venv python: + source project/backend/.venv/bin/activate + python monitor/ + + diff --git a/python-recipes/parallel-investor-signals/project/package-lock.json b/python-recipes/parallel-investor-signals/project/package-lock.json new file mode 100644 index 0000000..4401ffa --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/package-lock.json @@ -0,0 +1,2211 @@ +{ + "name": "project", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "project", + "version": "0.0.0", + "dependencies": { + "papaparse": "^5.5.4", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^24.13.2", + "@types/papaparse": "^5.5.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.73.0.tgz", + "integrity": "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.73.0.tgz", + "integrity": "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.73.0.tgz", + "integrity": "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.73.0.tgz", + "integrity": "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.73.0.tgz", + "integrity": "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.73.0.tgz", + "integrity": "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.73.0.tgz", + "integrity": "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.73.0.tgz", + "integrity": "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.73.0.tgz", + "integrity": "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.73.0.tgz", + "integrity": "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.73.0.tgz", + "integrity": "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.73.0.tgz", + "integrity": "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.73.0.tgz", + "integrity": "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.73.0.tgz", + "integrity": "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.73.0.tgz", + "integrity": "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.73.0.tgz", + "integrity": "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.73.0.tgz", + "integrity": "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.73.0.tgz", + "integrity": "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.73.0.tgz", + "integrity": "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxlint": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz", + "integrity": "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.73.0", + "@oxlint/binding-android-arm64": "1.73.0", + "@oxlint/binding-darwin-arm64": "1.73.0", + "@oxlint/binding-darwin-x64": "1.73.0", + "@oxlint/binding-freebsd-x64": "1.73.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", + "@oxlint/binding-linux-arm-musleabihf": "1.73.0", + "@oxlint/binding-linux-arm64-gnu": "1.73.0", + "@oxlint/binding-linux-arm64-musl": "1.73.0", + "@oxlint/binding-linux-ppc64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-musl": "1.73.0", + "@oxlint/binding-linux-s390x-gnu": "1.73.0", + "@oxlint/binding-linux-x64-gnu": "1.73.0", + "@oxlint/binding-linux-x64-musl": "1.73.0", + "@oxlint/binding-openharmony-arm64": "1.73.0", + "@oxlint/binding-win32-arm64-msvc": "1.73.0", + "@oxlint/binding-win32-ia32-msvc": "1.73.0", + "@oxlint/binding-win32-x64-msvc": "1.73.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/python-recipes/parallel-investor-signals/project/package.json b/python-recipes/parallel-investor-signals/project/package.json new file mode 100644 index 0000000..763da7c --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/package.json @@ -0,0 +1,31 @@ +{ + "name": "project", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest" + }, + "dependencies": { + "papaparse": "^5.5.4", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^24.13.2", + "@types/papaparse": "^5.5.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vitest": "^4.1.10" + } +} \ No newline at end of file diff --git a/python-recipes/parallel-investor-signals/project/public/favicon.svg b/python-recipes/parallel-investor-signals/project/public/favicon.svg new file mode 100644 index 0000000..1defc7a --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/public/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/python-recipes/parallel-investor-signals/project/requirements.txt b/python-recipes/parallel-investor-signals/project/requirements.txt new file mode 100644 index 0000000..93e42ce --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/requirements.txt @@ -0,0 +1,5 @@ +# Vercel Python function deps (subset of backend/requirements.txt — no +# uvicorn/uvloop/watchfiles: Vercel provides the ASGI server). +fastapi==0.139.0 +parallel-web==1.1.0 +python-dotenv==1.2.2 diff --git a/python-recipes/parallel-investor-signals/project/src/App.tsx b/python-recipes/parallel-investor-signals/project/src/App.tsx new file mode 100644 index 0000000..fa708b5 --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/App.tsx @@ -0,0 +1,317 @@ +import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"; +import type { CustomFieldResult, Depth, ResearchBrief } from "./types"; +import { ApiError, enrichCompany } from "./lib/api"; +import { MOCK_BRIEF } from "./lib/mock"; +import { getCached, recentQueries, saveCached } from "./lib/cache"; +import { + deleteProfile, + findProfile, + listProfiles, + saveProfile, + type SavedProfile, +} from "./lib/profiles"; +import { useTheme } from "./lib/useTheme"; +import { clearAccessKey, getAccessKey } from "./lib/auth"; +import { PasswordGate } from "./components/PasswordGate"; +import { Header, SHOW_BULK, SHOW_SIGNALS, type Mode } from "./components/Header"; +import { EnrichSearchBar } from "./components/EnrichSearchBar"; +import { AskBar } from "./components/AskBar"; +import { BriefHeader } from "./components/BriefHeader"; +import { AccountCard } from "./components/AccountCard"; +import { ContactsTable } from "./components/ContactsTable"; +import { HomePage } from "./components/HomePage"; +import { EmptyState, ErrorState } from "./components/States"; + +// Code-split the secondary views: most sessions never open Bulk or Signals, +// so they load on demand instead of padding the main bundle. +const BulkPanel = lazy(() => + import("./components/BulkPanel").then((m) => ({ default: m.BulkPanel })), +); +const SignalsPanel = lazy(() => + import("./components/SignalsPanel").then((m) => ({ default: m.SignalsPanel })), +); +import { LiveResearchState } from "./components/LiveResearchState"; +import { SourceDrawer } from "./components/SourceDrawer"; +import { SourceDrawerContext, type SourceRequest } from "./components/SourceDrawerContext"; + +export default function App() { + const { theme, toggle } = useTheme(); + // Demo access gate: a stored key means previously unlocked. The backend is + // the enforcement point — a stale/rotated key just 401s and re-gates below. + const [authed, setAuthed] = useState(() => Boolean(getAccessKey())); + // Land on the dashboard: saved profiles are the daily entry point. + const [mode, setMode] = useState("home"); + + // Saved company profiles (localStorage; the Home dashboard's data). + const [profiles, setProfiles] = useState(() => listProfiles()); + + // Single-lookup state + const [depth, setDepth] = useState("fast"); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [brief, setBrief] = useState(null); + // When the shown brief came from the session cache or a saved profile, this + // is its saved-at time (used for the honest "cached · Xm ago" badge). + const [cachedAt, setCachedAt] = useState(null); + const [error, setError] = useState<{ message: string; hint?: string } | null>(null); + const [lastQuery, setLastQuery] = useState(""); + const [recents, setRecents] = useState(() => recentQueries()); + + // Source drawer (lifted to the root; opened from anywhere via context) + const [source, setSource] = useState(null); + + // Whether the on-screen brief has a saved profile (kept in sync on changes). + const briefSaved = brief + ? profiles.some((p) => p.query === brief.query.trim().toLowerCase()) + : false; + + // If this company is saved, push the latest brief into its profile. + const syncProfile = useCallback((b: ResearchBrief) => { + if (findProfile(b.query)) { + saveProfile(b); + setProfiles(listProfiles()); + } + }, []); + + // Dev/design aid: ?mock=1 renders a canned brief instantly (clearly not a + // live run — meta.run_ids are trun_mock_*). Never used in the normal flow. + useEffect(() => { + if (new URLSearchParams(window.location.search).get("mock") === "1") { + setBrief(MOCK_BRIEF); + setLastQuery(MOCK_BRIEF.query); + setQuery(MOCK_BRIEF.query); + setMode("single"); + } + }, []); + + + const runEnrich = useCallback( + async (query: string, opts?: { force?: boolean }) => { + setLastQuery(query); + setQuery(query); // keep the search input in sync (chips, retry, refresh) + setError(null); + + // Session cache: instant re-show during a call (unless a live refresh + // was requested). Cached results are labeled as such — never passed off + // as a fresh run. + if (!opts?.force) { + const hit = getCached(query, depth); + if (hit) { + setBrief(hit.brief); + setCachedAt(hit.savedAt); + return; + } + } + + setLoading(true); + setBrief(null); + setCachedAt(null); + try { + const result = await enrichCompany(query, depth); + setBrief(result); + saveCached(query, depth, result); + syncProfile(result); // saved company? keep its profile fresh + setRecents(recentQueries()); + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + // Key was rotated/invalid: drop it and fall back to the gate. + clearAccessKey(); + setAuthed(false); + } else if (e instanceof ApiError) { + setError({ message: e.message, hint: e.hint }); + } else { + setError({ message: "Something went wrong reaching the enrichment service." }); + } + } finally { + setLoading(false); + } + }, + [depth, syncProfile], + ); + + // Deep link: ?q= auto-runs a lookup once unlocked — this is + // what the Slack "Enrich →" buttons open. Runs at most once. + const deepLinkRan = useRef(false); + useEffect(() => { + if (!authed || deepLinkRan.current) return; + const q = new URLSearchParams(window.location.search).get("q"); + if (q && q.trim()) { + deepLinkRan.current = true; + setMode("single"); + runEnrich(q.trim()); + } + }, [authed, runEnrich]); + + // "Add to profile" from the ask bar: append the cited answer to the brief's + // Custom Research card (deduped by question) and persist wherever it lives. + const addToProfile = useCallback( + (result: CustomFieldResult) => { + if (!brief) return; + const norm = (s: string) => s.trim().toLowerCase(); + const existing = brief.custom_fields ?? []; + if (existing.some((c) => norm(c.question) === norm(result.question))) return; + const next = { ...brief, custom_fields: [...existing, result] }; + setBrief(next); + saveCached(next.query, depth, next); + syncProfile(next); + }, + [brief, depth, syncProfile], + ); + + // --- Home dashboard handlers --- + const openProfile = useCallback((p: SavedProfile) => { + setBrief(p.brief); + setQuery(p.brief.query); + setLastQuery(p.brief.query); + setCachedAt(p.updatedAt); + setError(null); + setMode("single"); + }, []); + + const handleDeleteProfile = useCallback((id: string) => { + setProfiles(deleteProfile(id)); + }, []); + + const handleNewProfile = useCallback(() => { + setBrief(null); + setQuery(""); + setLastQuery(""); + setCachedAt(null); + setError(null); + setMode("single"); + }, []); + + const handleSaveProfile = useCallback(() => { + if (!brief) return; + saveProfile(brief); + setProfiles(listProfiles()); + }, [brief]); + + // "/" focuses the search input from anywhere (unless already typing). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return; + const el = document.activeElement; + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return; + e.preventDefault(); + document.getElementById("enrich-input")?.focus(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + // Everything (including ?mock=1) sits behind the gate. + if (!authed) { + return setAuthed(true)} />; + } + + return ( + +
+
{ + if (m === "home") setProfiles(listProfiles()); + setMode(m); + }} + /> + +
+ {mode === "home" && ( + + )} + + {mode === "single" && ( + <> + + + {/* Recent lookups — cached, so tapping one is instant */} + {!loading && recents.length > 0 && ( +
+ + Recent + + {recents.map((r) => ( + + ))} +
+ )} + + {loading && } + {!loading && error && ( + lastQuery && runEnrich(lastQuery, { force: true })} + /> + )} + {!loading && !error && !brief && } + {!loading && !error && brief && ( +
+ runEnrich(brief.query, { force: true })} + saved={briefSaved} + onSave={handleSaveProfile} + /> + +
+ +
+
+

+ Decision-makers +

+ +
+
+ )} + + )} + + {mode === "bulk" && SHOW_BULK && ( + Loading…
}> + + + )} + + {mode === "signals" && SHOW_SIGNALS && ( + Loading…}> + { + // Hand a signal straight to the single-lookup flow — the + // signal→brief pipeline in one click. + setMode("single"); + runEnrich(company); + }} + /> + + )} + + + setSource(null)} /> + +
+ ); +} diff --git a/python-recipes/parallel-investor-signals/project/src/assets/logo/dark.svg b/python-recipes/parallel-investor-signals/project/src/assets/logo/dark.svg new file mode 100644 index 0000000..0bee08e --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/assets/logo/dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/python-recipes/parallel-investor-signals/project/src/assets/logo/light.svg b/python-recipes/parallel-investor-signals/project/src/assets/logo/light.svg new file mode 100644 index 0000000..1defc7a --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/assets/logo/light.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/python-recipes/parallel-investor-signals/project/src/assets/logo/wordmark_dark.svg b/python-recipes/parallel-investor-signals/project/src/assets/logo/wordmark_dark.svg new file mode 100644 index 0000000..92d8304 --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/assets/logo/wordmark_dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/python-recipes/parallel-investor-signals/project/src/assets/logo/wordmark_light.svg b/python-recipes/parallel-investor-signals/project/src/assets/logo/wordmark_light.svg new file mode 100644 index 0000000..2318366 --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/assets/logo/wordmark_light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/python-recipes/parallel-investor-signals/project/src/brand/parallel-theme.css b/python-recipes/parallel-investor-signals/project/src/brand/parallel-theme.css new file mode 100644 index 0000000..2cb90d0 --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/brand/parallel-theme.css @@ -0,0 +1,131 @@ +/* ========================================================================== + Parallel brand theme — drop-in CSS + Source of truth: the official Parallel docs (docs.json colors + style.css). + Import this once at the app root, then use the CSS variables below. + ========================================================================== */ + +/* --- Brand fonts (canonical, hosted by Parallel) -------------------------- */ +/* Gerstner Programm = display/body. FT System Mono = code/mono. + These are proprietary fonts served from Parallel's CDN; if they fail to + load (offline, or licensing), the fallback stacks below take over cleanly. */ + +@font-face { + font-family: "Gerstner Programm"; + src: url("https://assets.p0web.com/Gerstner-ProgrammRegular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Gerstner Programm"; + src: url("https://assets.p0web.com/Gerstner-ProgrammMedium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "FT System Mono"; + src: url("https://assets.p0web.com/FTSystemMono-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "FT System Mono"; + src: url("https://assets.p0web.com/FTSystemMono-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +/* --- Design tokens -------------------------------------------------------- */ +:root { + /* Brand */ + --parallel-orange: #fb631b; /* primary — the single accent. Use sparingly. */ + --parallel-ink: #1d1c1a; /* near-black brand ink (logo fill on light) */ + + /* Fonts */ + --font-sans: "Gerstner Programm", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + --font-mono: "FT System Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + monospace; + + /* Radius (docs use rounded-md everywhere for cards + code) */ + --radius: 0.375rem; + + /* Semantic — LIGHT theme (docs default appearance) */ + --bg: #fcfcfa; /* app background (warm off-white) */ + --surface: #f6f6f6; /* cards / panels */ + --surface-2: #eeeeee; /* active/selected surface */ + --border: #e5e5e5; + --text: #1d1c1a; /* primary text */ + --text-muted: #4b5563; + --accent: var(--parallel-orange); + --on-accent: #fcfcfa; /* text/icon on an orange fill */ +} + +/* Semantic — DARK theme. Toggle by adding class="dark" on /, + or rely on prefers-color-scheme below. */ +.dark, +:root[data-theme="dark"] { + --bg: #1d1b16; /* warm near-black */ + --surface: #1a1a1a; + --surface-2: #2a2a2a; + --border: #2a2a2a; + --text: #fcfcfa; + --text-muted: #9ca3af; + --accent: var(--parallel-orange); + --on-accent: #fcfcfa; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]):not(.light) { + --bg: #1d1b16; + --surface: #1a1a1a; + --surface-2: #2a2a2a; + --border: #2a2a2a; + --text: #fcfcfa; + --text-muted: #9ca3af; + } +} + +/* --- Base element styling ------------------------------------------------- */ +html, +body { + background-color: var(--bg); + color: var(--text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* Parallel headings read at medium weight (500), not bold — a signature of + the brand's restrained, editorial feel. */ +h1, h2, h3, h4, h5, h6 { + font-weight: 500; + letter-spacing: -0.01em; +} + +code, pre, kbd, samp { + font-family: var(--font-mono); +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { text-decoration: underline; } + +/* Utility surfaces you can apply directly */ +.parallel-card { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.parallel-btn { + background-color: var(--accent); + color: var(--on-accent); + border-radius: var(--radius); + font-family: var(--font-sans); + font-weight: 500; +} diff --git a/python-recipes/parallel-investor-signals/project/src/components/AccountCard.tsx b/python-recipes/parallel-investor-signals/project/src/components/AccountCard.tsx new file mode 100644 index 0000000..d2c9e44 --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/components/AccountCard.tsx @@ -0,0 +1,128 @@ +import type { ResearchBrief } from "../types"; +import { FieldRow, SourceMarker } from "./FieldRow"; +import { ConfidencePill } from "./ConfidencePill"; + +// A titled group of claims. The eyebrow number encodes nothing sequential — +// it's a section index for scannability, kept in mono to match the console. +function Section({ + index, + title, + className, + children, +}: { + index: string; + title: string; + className?: string; + children: React.ReactNode; +}) { + return ( +
+
+ {index} +

+ {title} +

+
+
{children}
+
+ ); +} + +export function AccountCard({ brief }: { brief: ResearchBrief }) { + const { firmographics: f, funding: fn, technographics: t, buying_signals: bs } = brief; + const custom = brief.custom_fields ?? []; + + return ( +
+
+ + + + + +
+ +
+ + + ( + + {v.map((inv) => ( + + {inv} + + ))} + + )} + /> + + +
+ +
+ ( + + {v.map((tech) => ( + + {tech} + + ))} + + )} + /> +
+ +
+ {bs.value && bs.value.length ? ( +
+
+ {bs.confidence && } + +
+
    + {bs.value.map((s, i) => ( +
  • + + {s.type} + +
    +

    {s.headline}

    + {s.date && ( +

    {s.date}

    + )} +
    +
  • + ))} +
+
+ ) : ( +

No recent signals found.

+ )} +
+ + {custom.length > 0 && ( +
+ {custom.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/python-recipes/parallel-investor-signals/project/src/components/AskBar.tsx b/python-recipes/parallel-investor-signals/project/src/components/AskBar.tsx new file mode 100644 index 0000000..b3feb7b --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/components/AskBar.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from "react"; +import type { CustomFieldResult, Depth, ResearchBrief } from "../types"; +import { ApiError, enrichCustomFields } from "../lib/api"; +import { SourceMarker } from "./FieldRow"; +import { ConfidencePill } from "./ConfidencePill"; + +// Ad-hoc research about the company on screen: ask a question, get a cited +// answer inline. Nothing touches the profile unless the rep clicks "Add to +// profile" — answers here are ephemeral (and reset when the company changes). +export function AskBar({ + brief, + depth, + onAddToProfile, +}: { + brief: ResearchBrief; + depth: Depth; + onAddToProfile: (result: CustomFieldResult) => void; +}) { + const [question, setQuestion] = useState(""); + const [asking, setAsking] = useState(false); + const [pending, setPending] = useState(null); // question in flight + const [answers, setAnswers] = useState([]); // newest first + const [error, setError] = useState(null); + + // New company on screen -> clear the session Q&A. + const companyRef = useRef(brief.query); + useEffect(() => { + if (companyRef.current !== brief.query) { + companyRef.current = brief.query; + setAnswers([]); + setQuestion(""); + setError(null); + } + }, [brief.query]); + + const norm = (s: string) => s.trim().toLowerCase(); + const inProfile = new Set((brief.custom_fields ?? []).map((c) => norm(c.question))); + + const ask = async () => { + const q = question.trim(); + if (!q || asking) return; + // Already answered this session — just surface it (no repeat spend). + if (answers.some((a) => norm(a.question) === norm(q))) { + setQuestion(""); + return; + } + setError(null); + setAsking(true); + setPending(q); + try { + const res = await enrichCustomFields(brief.query, depth, [ + { label: q.slice(0, 60), question: q }, + ]); + const answer = res.custom_fields[0]; + if (answer) setAnswers((prev) => [answer, ...prev]); + setQuestion(""); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Research failed. Try again."); + } finally { + setAsking(false); + setPending(null); + } + }; + + return ( +
+
+
+ + ? + + setQuestion(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && ask()} + placeholder={`Ask anything about ${brief.company_name} — e.g. Are they SOC 2 compliant?`} + disabled={asking} + className="w-full rounded-brand border border-line bg-surface py-2.5 pl-8 pr-3 text-[14px] text-ink outline-none transition-colors placeholder:text-muted/50 focus:border-accent disabled:opacity-60" + /> +
+ +
+

+ Answered live from the web, with citations. No cited source → no answer. +

+ + {error &&

{error}

} + + {(pending || answers.length > 0) && ( +
+ {pending && ( +
+

{pending}

+ researching… +
+ )} + {answers.map((a) => { + const added = inProfile.has(norm(a.question)); + const has = a.field.value !== null; + return ( +
+

{a.question}

+
+ {has ? ( + <> + + {String(a.field.value)} + + {a.field.confidence && } + + + + ) : ( + + No cited answer found — not guessing. + + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/python-recipes/parallel-investor-signals/project/src/components/BriefHeader.tsx b/python-recipes/parallel-investor-signals/project/src/components/BriefHeader.tsx new file mode 100644 index 0000000..d31d3f2 --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/components/BriefHeader.tsx @@ -0,0 +1,114 @@ +import type { ResearchBrief } from "../types"; +import { formatLatency } from "../lib/format"; +import { agoLabel } from "../lib/cache"; + +// Counts how many of the ~15 account fields came back with a value — a quick +// "signal strength" read for the rep, and the trigger for a low-signal banner. +function coverage(brief: ResearchBrief): { filled: number; total: number } { + const f = brief.firmographics; + const fn = brief.funding; + const fields = [ + f.industry, f.hq, f.employee_count, f.founded_year, f.description, + fn.total_raised, fn.last_round, fn.investors, fn.valuation, fn.revenue_estimate, + brief.technographics.tech_stack, brief.buying_signals, + ]; + const filled = fields.filter((x) => { + const v = x.value; + return v !== null && v !== undefined && (!Array.isArray(v) || v.length > 0); + }).length; + return { filled, total: fields.length }; +} + +export function BriefHeader({ + brief, + cachedAt, + onRefresh, + saved = false, + onSave, +}: { + brief: ResearchBrief; + cachedAt?: number | null; + onRefresh?: () => void; + saved?: boolean; // this company already has a saved profile (kept in sync) + onSave?: () => void; +}) { + const { filled, total } = coverage(brief); + const lowSignal = filled <= 3; + + return ( +
+
+
+

{brief.company_name}

+ {brief.domain && ( + + {brief.domain} ↗ + + )} +
+
+ {/* Honest provenance: cached results say so, with one-click live refresh */} + {cachedAt ? ( + <> + + cached {agoLabel(cachedAt)} + + {onRefresh && ( + + )} + + ) : ( + + {formatLatency(brief.meta.latency_ms)} + + )} + + {brief.meta.processor} + + + {filled}/{total} fields + + {onSave && + (saved ? ( + + Saved ✓ + + ) : ( + + ))} +
+
+ + {lowSignal && ( +
+ Low signal — we found little public data for this query. Try the full + company name or its primary domain. +
+ )} + {brief.meta.partial && !lowSignal && ( +
+ Partial result — some fields timed out. The values shown are still + fully sourced. +
+ )} +
+ ); +} diff --git a/python-recipes/parallel-investor-signals/project/src/components/BulkPanel.tsx b/python-recipes/parallel-investor-signals/project/src/components/BulkPanel.tsx new file mode 100644 index 0000000..0c403c8 --- /dev/null +++ b/python-recipes/parallel-investor-signals/project/src/components/BulkPanel.tsx @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import Papa from "papaparse"; +import type { BulkJob, CustomFieldDef, Depth, ResearchBrief } from "../types"; +import { downloadBulkExport, pollBulk, startBulk } from "../lib/api"; +import { usableDefs } from "../lib/customFields"; +import { CustomFieldsEditor } from "./CustomFieldsEditor"; + +// Pull a list of company identifiers out of an uploaded CSV: prefer a column +// named company/domain/name/account; otherwise fall back to the first column. +function companiesFromCsv(text: string): string[] { + const parsed = Papa.parse(text.trim(), { skipEmptyLines: true }); + const rows = parsed.data as string[][]; + if (!rows.length) return []; + const header = rows[0].map((h) => (h || "").toLowerCase().trim()); + const preferred = ["company", "domain", "name", "account", "website"]; + let col = header.findIndex((h) => preferred.includes(h)); + const hasHeader = col !== -1 || header.some((h) => preferred.some((p) => h.includes(p))); + if (col === -1) col = 0; + const body = hasHeader ? rows.slice(1) : rows; + return body.map((r) => (r[col] || "").trim()).filter(Boolean); +} + +export function BulkPanel({ depth }: { depth: Depth }) { + const [companies, setCompanies] = useState([]); + const [raw, setRaw] = useState(""); + const [job, setJob] = useState(null); + const [error, setError] = useState(null); + // Batch-level custom questions -> one extra column each. Session-only state: + // deliberately not persisted, so a refresh starts clean. + const [customFields, setCustomFields] = useState([]); + // `submittedDefs` is frozen at run time so result columns stay stable while + // the job runs (independent of further edits to the editor). + const [submittedDefs, setSubmittedDefs] = useState([]); + const pollRef = useRef(null); + const fileRef = useRef(null); + + const running = job?.status === "running"; + + // Parse a pasted list (newline or comma separated) into companies. + const parsePasted = useCallback((text: string) => { + setRaw(text); + const list = text + .split(/[\n,]/) + .map((s) => s.trim()) + .filter(Boolean); + setCompanies(list); + }, []); + + const onFile = (file: File) => { + const reader = new FileReader(); + reader.onload = () => { + const list = companiesFromCsv(String(reader.result)); + setCompanies(list); + setRaw(list.join("\n")); + }; + reader.readAsText(file); + }; + + const run = async () => { + if (!companies.length || running) return; + setError(null); + setJob(null); + const defs = usableDefs(customFields); + setSubmittedDefs(defs); + try { + const { job_id } = await startBulk(companies, depth, defs); + setJob({ job_id, status: "running", done: 0, total: companies.length, results: [] }); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to start enrichment."); + } + }; + + const exportCsv = async () => { + if (!job) return; + setError(null); + try { + await downloadBulkExport(job.job_id); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to export the CSV."); + } + }; + + // Poll the job while it runs. Cleaned up on unmount / completion. + useEffect(() => { + if (!job || job.status !== "running") return; + pollRef.current = window.setInterval(async () => { + try { + const next = await pollBulk(job.job_id); + setJob(next); + } catch (e) { + setError(e instanceof Error ? e.message : "Lost connection to the job."); + if (pollRef.current) window.clearInterval(pollRef.current); + } + }, 1500); + return () => { + if (pollRef.current) window.clearInterval(pollRef.current); + }; + }, [job?.job_id, job?.status]); + + return ( +
+
+

Bulk enrich a list

+

+ Paste companies (one per line) or drop a CSV. We enrich every row and + give you an enriched CSV back — the Monday-morning rep workflow. +

+ +
+