diff --git a/.gitignore b/.gitignore index 89aee6a..de4cdd0 100644 --- a/.gitignore +++ b/.gitignore @@ -35,8 +35,12 @@ data/datasets/ data/models/ server/data/ -# Claude Code +# Agent tooling .claude/ +.gemini/ + +# Logs +*.log # Environment .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d7936d..48f2f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,284 +1,5 @@ # Changelog -All notable changes to Ehrlich will be documented in this file. +Project history is tracked via phase documents in the roadmap. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - -## [Unreleased] - -### Added - -- Token optimization: prompt caching via `cache_control: ephemeral` on system messages (90% cheaper cached tokens across 20-40 calls) -- Token optimization: phase-based tool filtering -- researcher gets only planned tools per experiment (5-10 instead of 23) -- Token optimization: structured extraction (`_compact_result`) strips verbose fields before summarizer check, eliminating 50-80% of Haiku calls -- Token optimization: in-memory TTL tool cache (`ToolCache`) -- deterministic tools cached forever, API results cached 24h-7d -- Per-experiment Lab View: `experiment_id` field on `ToolCalled` and `ToolResultEvent` domain events -- Per-experiment Lab View: experiment selector dropdown in `LiveLabViewer` for filtering 3D scene by experiment -- Per-experiment Lab View: `eventsByExperiment` state in `use-sse.ts` for grouping SSE events -- Data source: UniProt REST client (`uniprot_client.py`) -- protein function, disease associations, GO terms, PDB cross-refs -- Data source: Open Targets GraphQL client (`opentargets_client.py`) -- disease-target associations with scored evidence -- Data source: GtoPdb REST client (`gtopdb_client.py`) -- expert-curated receptor/ligand interactions with affinities -- Tools: `get_protein_annotation` -- UniProt protein annotations (tool #28) -- Tools: `search_disease_targets` -- Open Targets disease-target associations (tool #29) -- Tools: `search_pharmacology` -- GtoPdb curated pharmacology data (tool #30) -- Domain entities: `ProteinAnnotation`, `TargetAssociation`, `PharmacologyEntry` -- Repository ABCs: `ProteinAnnotationRepository`, `TargetAssociationRepository`, `PharmacologyRepository` -- Tests: UniProt, Open Targets, GtoPdb client tests with respx mocking -- Parallel researchers: `_run_experiment_batch()` runs 2 experiments concurrently via `asyncio.Queue` event merging -- Parallel researchers: `asyncio.Lock` protects shared `Investigation` state and `CostTracker` during concurrent execution -- Prompt engineering: XML-tagged instructions (``, ``, ``) in all Director and Researcher prompts -- Prompt engineering: multishot examples in `DIRECTOR_FORMULATION_PROMPT` (MRSA + Alzheimer's domains) and `DIRECTOR_EXPERIMENT_PROMPT` -- Prompt engineering: tool usage examples in `RESEARCHER_EXPERIMENT_PROMPT` (docking, model training, disease-target search) -- Prompt engineering: search strategy guidance ("start broad, then narrow") from Anthropic multi-agent research patterns -- Context compaction: `_build_prior_context()` compresses completed hypotheses into XML summary for Director between batches - -### Changed - -- Always uses `MultiModelOrchestrator` (single-model `Orchestrator` removed) -- Hypothesis loop runs batched parallel execution (up to 2 hypotheses per batch) -- Tool count: 27 -> 30 (3 new data source tools) -- All prompts rewritten with XML structure, multishot examples, and Anthropic best practices -- E2E tests: rewritten to use `MultiModelOrchestrator` -- Server tests: 267 -> 271 passing (80.2% coverage) - -### Removed - -- `Orchestrator` class (`orchestrator.py`) -- single-model fallback path eliminated -- `test_orchestrator.py` -- tests for removed class - ---- - -### Added - -- Auditability: SSE event persistence in SQLite `events` table -- all events stored during investigation, replayed on page reload -- Auditability: `save_event()` / `get_events()` methods on `InvestigationRepository` interface and SQLite implementation -- Auditability: `prompt` field included in `completed` SSE event during replay (research question visible in report) -- Console: `InvestigationReport` -- structured 8-section report replacing plain-text ReportViewer (Research Question, Executive Summary, Hypotheses & Outcomes, Methodology, Key Findings, Candidate Molecules, Model Validation, Cost & Performance) -- Console: `DiagramRenderer` -- React Flow (`@xyflow/react`) investigation diagram with custom node types, minimap, controls -- Console: Dark-friendly diagram palette with dark fills and light text -- Console: Diagram zoom, pan, and minimap via React Flow built-ins -- Dependencies: `@xyflow/react` for node-graph diagrams - -### Changed - -- Console: `InvestigationDiagram` uses React Flow instead of Excalidraw (lazy-loaded, code-split ~188KB chunk) -- Console: `diagram-builder.ts` outputs React Flow `Node[]` + `Edge[]` with `smoothstep` routing instead of Excalidraw skeletons -- Console: Investigation diagram is always read-only (`nodesDraggable={false}`) -- Console: Investigation page shows `FindingsPanel` during live investigation, full `InvestigationReport` after completion -- Console: Completed investigation replay includes full timeline (all SSE events) instead of just final status -- Server: `_replay_final()` yields stored events before completed event for full timeline reconstruction - -### Removed - -- Console: `ExcalidrawWrapper` component (replaced by `DiagramRenderer`) -- Console: `ReportViewer` component (replaced by `InvestigationReport`) -- Console: `@excalidraw/excalidraw` dependency removed from `package.json` - ---- - -### Added - -- Domain-agnostic engine: RCSB PDB client for dynamic protein target discovery by organism/function/keyword -- Domain-agnostic engine: PubChem PUG REST client for compound search by name/SMILES/similarity -- Domain-agnostic engine: EPA CompTox CTX API client for environmental toxicity profiles (LD50, LC50, BCF, mutagenicity) -- Domain-agnostic engine: Flexible ChEMBL bioactivity search supporting any assay type (Ki, EC50, Kd, MIC, IC50) -- Tools: `search_protein_targets` -- RCSB PDB search by organism/function (tool #24) -- Tools: `search_compounds` -- PubChem compound search by name/SMILES/similarity (tool #25) -- Tools: `search_bioactivity` -- flexible ChEMBL query with configurable assay types (tool #26) -- Tools: `fetch_toxicity_profile` -- EPA CompTox environmental toxicity data (tool #27) -- Domain entities: `ToxicityProfile`, `CompoundSearchResult` -- Repository ABCs: `ProteinTargetRepository`, `ToxicityRepository`, `CompoundSearchRepository` -- Data: `data/targets/default.yaml` -- YAML-configured protein targets (replaces hardcoded dict) -- Data: `data/resistance/default.yaml` -- YAML-configured resistance mutations and SMARTS patterns -- Config: `EHRLICH_COMPTOX_API_KEY` setting for EPA CompTox API access -- Console: `LiveLabViewer` -- 3Dmol.js WebGL scene driven by SSE events (proteins load, ligands dock, candidates color by score) -- Console: `InvestigationDiagram` -- Excalidraw hypothesis map with status-colored nodes and evidence-chain arrows -- Console: `ExcalidrawWrapper` -- lazy-loaded Excalidraw component with dark theme -- Console: `scene-builder.ts` -- maps SSE events to 3Dmol.js operations (addProtein, addLigand, colorByScore, highlight) -- Console: `diagram-builder.ts` -- converts investigation data to Excalidraw elements (nodes, arrows, labels) -- Console: Tab system on investigation page (Timeline | Lab View | Diagram) -- Tests: 45+ new tests for RCSB, PubChem, CompTox clients, resistance YAML loader, new tool functions -- Dependencies: `pyyaml`, `respx` (dev), `@excalidraw/excalidraw` - -### Changed - -- Engine generalized from antimicrobial-only to domain-agnostic molecular discovery (23 -> 27 tools) -- Prompts: all "antimicrobial scientist" language replaced with "molecular discovery scientist" -- Prompts: added domain-adaptive instruction ("The user's research question defines the domain") -- Prompts: tool references updated to include 4 new data source tools -- Prediction docstrings: "antimicrobial activity" -> "bioactivity" -- `ProteinStore`: hardcoded 5-target dict replaced with YAML loader + RCSB PDB fallback search -- `SimulationService`: hardcoded resistance mutations/patterns replaced with YAML-loaded data -- `ChEMBLLoader`: parameterized `assay_types` (was hardcoded `MIC,IC50`), type-aware cache keys -- `AnalysisService`: added `compound_repo` for PubChem integration -- Tool registry: 23 -> 27 tools registered in investigation routes -- Server tests: 212 -> 257 passing (80.6% coverage) - ---- - -### Added - -- Investigation: hypothesis-driven scientific method replacing linear pipeline -- Investigation: `propose_hypothesis` tool -- register testable hypotheses with rationale -- Investigation: `design_experiment` tool -- plan experiments with tool sequences -- Investigation: `evaluate_hypothesis` tool -- assess outcomes (supported/refuted/revised) with confidence -- Investigation: `record_finding` tool -- findings linked to hypothesis + evidence type -- Investigation: `record_negative_control` tool -- validate models with known-inactive compounds -- Investigation: `conclude_investigation` tool -- final summary with ranked candidates -- Domain: `Hypothesis` entity with status enum (proposed/testing/supported/refuted/revised) -- Domain: `Experiment` entity with status enum (planned/running/completed/failed) -- Domain: `NegativeControl` frozen dataclass -- Domain: 5 new events (HypothesisFormulated, HypothesisEvaluated, ExperimentStarted, ExperimentCompleted, NegativeControlRecorded) -- SSE: 5 new event types (hypothesis_formulated, hypothesis_evaluated, experiment_started, experiment_completed, negative_control) -- Console: `HypothesisBoard` -- kanban-style hypothesis status grid -- Console: `HypothesisCard` -- expandable card with confidence bar -- Console: `ActiveExperimentCard` -- live experiment activity card -- Console: `NegativeControlPanel` -- validation results table -- Console: `CompletionSummaryCard` -- post-completion counts (candidates, findings, hypotheses) -- Console: Findings replay from `InvestigationCompleted` event on page reload -- Console: Toast notifications via `sonner` for completion + error events -- Console: Custom scrollbar CSS (8px webkit + Firefox thin) with OKLCH theme - -### Changed - -- `MultiModelOrchestrator`: hypothesis-driven loop (formulate -> design -> execute -> evaluate per hypothesis) -- Director prompts: 4 phases (formulation, experiment, evaluation, synthesis) with domain-adaptive language -- Tool count: 19 -> 23 (6 investigation control tools added) -- SQLite repository: serializes hypothesis/experiment/negative_control data - ---- - -### Added - -- Branding: "Lab Protocol" dark-only visual identity (Industrial Scientific + Editorial + Cyberpunk Lab) -- Branding: OKLCH color system with Molecular Green primary (`oklch(0.72 0.19 155)`) -- Branding: Space Grotesk display/body font + JetBrains Mono data/label font via Google Fonts -- Branding: `pulse-glow` keyframe animation for active/running states -- Branding: Dark prose overrides for react-markdown content -- Console: Molecular bond phase progress (node-and-bond visualization replacing flat bars) -- Console: Section headers with monospace uppercase + left green border accent -- Data pipeline: `evidence` field threaded from Finding entity through FindingRecorded event to SSE to frontend -- Data pipeline: Candidate scoring fields (`prediction_score`, `docking_score`, `admet_score`, `resistance_risk`) populated from orchestrator prompts and serialized through SSE -- Console: Expandable Timeline events -- tool results, thinking, director decisions expand on click -- Console: Rich Director decision cards -- planning shows phase goals, review shows quality/gaps/guidance, synthesis shows confidence/limitations -- Console: FindingsPanel grid layout (2-3 columns) with evidence section -- Console: CandidateTable scoring columns (Prediction, Docking, ADMET, Resistance) with color-coded values -- Console: CostBadge per-model breakdown popover (Director, Researcher, Summarizer costs) -- Molecule visualization: server-side 2D SVG depiction via RDKit `rdMolDraw2D` (`depict_2d` on RDKitAdapter and ChemistryService) -- Molecule API: `GET /molecule/depict` returns `image/svg+xml` with 24h cache, error SVG for invalid SMILES -- Molecule API: `GET /molecule/conformer` returns 3D conformer JSON (mol_block, energy, num_atoms) -- Molecule API: `GET /molecule/descriptors` returns molecular descriptors + Lipinski pass/fail -- Molecule API: `GET /targets` returns list of protein targets (pdb_id, name, organism) -- Console: `MolViewer2D` component -- server-side SVG via `` tag with lazy loading and error fallback -- Console: `MolViewer3D` component -- 3Dmol.js WebGL viewer with stick style and Jmol coloring -- Console: `DockingViewer` component -- 3Dmol.js protein (cartoon/spectrum) + ligand (stick/green) overlay -- Console: `CandidateDetail` expandable panel with 2D/3D views, property card, and Lipinski badge -- Console: `CandidateTable` rewrite with 2D structure thumbnails, expand/collapse rows, chevron indicators -- Console: 3Dmol.js dependency for 3D molecular visualization -- Tests: 10 molecule API tests, 3 RDKit depict tests, 4 MolViewer2D component tests - -### Removed - -- Console: `useRDKit` hook (dead code, replaced by server-side SVG rendering) - -### Changed - -- Console: Complete dark theme overhaul — all 15 components restyled for dark background -- Console: AppLayout header redesigned with green bar accent, monospace alpha badge, right-aligned label -- Console: Home page left-aligned (anti-AI pattern), mono stat line "19 tools · 7 phases · multi-model" -- Console: Investigation page redesigned as full-width vertical stack (was 2/3+1/3 grid) -- Console: Timeline events restyled — green findings, amber director events, mono tool names -- Console: CostBadge uses monospace with green-highlighted cost amount, clickable for model breakdown -- Console: PromptInput dark textarea with green focus ring and glow hover on button -- Console: InvestigationList dark cards with green border hover effect -- Console: FindingsPanel dark surface cards with green file icon -- Console: ReportViewer uses `prose-invert` with custom dark prose colors -- Console: Lipinski badges use `bg-primary/20` (pass) and `bg-destructive/20` (fail) -- Molecule: 3Dmol.js viewers use dark background (`#1a1e1a`) instead of white -- Molecule: Error SVG uses dark background matching surface color -- Molecule: MolViewer2D error fallback uses `bg-surface` instead of `bg-white` -- `CandidateTable` replaces raw SMILES text column with inline 2D structure thumbnails (100x75) -- `CandidateTable` rows are now clickable to expand `CandidateDetail` panel -- `FindingsPanel` shows full detail text (removed 200-char truncation) -- Tool result preview increased from 500 to 1500 chars in both orchestrators -- Prompts: `DIRECTOR_SYNTHESIS_PROMPT` and `SCIENTIST_SYSTEM_PROMPT` request candidate scoring fields - ---- - -### Added - -- Multi-model: Director-Worker-Summarizer architecture (Opus 4.6 plans, Sonnet 4.5 executes tools, Haiku 4.5 compresses large outputs) -- Multi-model: `MultiModelOrchestrator` with phase-based execution, director review, and final synthesis -- Multi-model: Per-model cost tracking with tiered pricing (Opus $15/$75, Sonnet $3/$15, Haiku $0.80/$4 per M tokens) -- Multi-model: 5 new prompts -- director planning, review, synthesis, researcher phase, summarizer -- Multi-model: 3 new domain events -- `DirectorPlanning`, `DirectorDecision`, `OutputSummarized` -- Multi-model: 3 new SSE event types -- `director_planning`, `director_decision`, `output_summarized` -- Persistence: SQLite repository with WAL mode via `aiosqlite` for investigation storage -- Persistence: `InvestigationRepository` ABC in domain layer -- API: `GET /investigate` -- list all investigations (most recent first) -- API: `GET /investigate/{id}` -- full investigation detail with findings, candidates, cost -- API: Orchestrator creation from model settings (always multi-model) -- Console: SSE reconnection with exponential backoff (1s, 2s, 4s, max 3 retries) -- Console: Investigation history list on home page with status badges and candidate counts -- Console: Phase progress bar (7-segment) with active/completed/inactive states -- Console: Director event rendering in Timeline (planning, decision, output summarized) -- Console: `useInvestigations` hook with TanStack Query (10s refetch interval) -- Investigation: `created_at` and `cost_data` fields on Investigation entity -- Config: Per-model settings (`EHRLICH_DIRECTOR_MODEL`, `EHRLICH_RESEARCHER_MODEL`, `EHRLICH_SUMMARIZER_MODEL`) -- Config: `EHRLICH_SUMMARIZER_THRESHOLD`, `EHRLICH_MAX_ITERATIONS_PER_PHASE`, `EHRLICH_DB_PATH` -- Tests: 25+ new tests for multi-model orchestrator, SQLite repository, updated API and cost tracker - -### Fixed - -- Added `pyarrow` to core dependencies -- required for ChEMBL parquet caching (`explore_dataset` crashed without it) - -### Changed - -- Cost tracker refactored to track per-model usage with model-specific pricing breakdown -- `AnthropicClientAdapter` exposes `model` property for cost attribution -- Orchestrator passes model to `cost.add_usage()` for accurate per-model tracking -- API routes use SQLite repository instead of in-memory dict -- `InvestigationCompleted` event includes `candidates` list -- SSE `completed` event includes candidates and cost data from investigation -- Integration: API key wired from `Settings` to `AnthropicClientAdapter` constructor -- Integration: Exponential backoff retry (3 attempts) for rate-limit and timeout errors -- Integration: Startup lifespan event with API key validation, tool count logging, optional dependency checks -- Integration: Data preparation script (`data/scripts/prepare_data.py`) using `ChEMBLLoader` for pre-downloading ChEMBL bioactivity data and PDB protein structures -- Integration: E2E smoke test exercising full pipeline (tool registry, orchestrator dispatch, SSE events) -- Integration: Environment variable documentation in README -- Integration: Demo instructions in README - -### Changed - -- Removed `modify_molecule` stub from tool registry (19 tools, down from 20) -- Improved error handling in `explore_dataset` (ChEMBL timeout, empty datasets) -- Improved error handling in `train_model` (dataset too small) -- Improved error handling in `predict_candidates` (model not found) -- Improved error handling in `dock_against_target` (invalid target, lists available targets) -- Improved error handling in `assess_resistance` (invalid target, invalid SMILES) -- Added `has_api_key` property to `Settings` for startup validation - -### Fixed - -- `AnthropicClientAdapter` no longer ignores `Settings.anthropic_api_key` (was reading only from env) - ---- - -- Initial project scaffolding with DDD architecture (6 bounded contexts) -- Server: Python 3.12 + FastAPI + uv with full domain/application/infrastructure layers -- Console: React 19 + TypeScript 5.7 + Bun + Vite 6 + TanStack Router -- Shared kernel: SMILES/InChIKey/MolBlock types, Molecule value object, exception hierarchy -- API layer: FastAPI factory with CORS, health endpoint, investigation route stub -- SSE event types for real-time investigation streaming -- Investigation feature: Timeline, PromptInput, CandidateTable, ReportViewer, CostBadge -- Molecule feature: MolViewer2D, MolViewer3D, DockingViewer, PropertyCard stubs -- CI/CD: GitHub Actions (server: ruff + mypy + pytest, console: build + typecheck + test) -- Docker: multi-stage builds for server (uv + python:3.12-slim) and console (bun + nginx) -- Data: core references JSON, data preparation script, protein directory -- Optional dependency groups: `docking` (vina, meeko), `deeplearning` (chemprop) -- Pre-commit hooks: ruff lint + format -- Chemistry context: full RDKit adapter (descriptors, fingerprints, conformers, scaffolds, Butina clustering, substructure matching), ChemistryService, 7 tools (44 tests) -- Literature context: Semantic Scholar client, PubMed stub, core reference set with 6 key papers, LiteratureService, 2 tools (11 tests) -- Analysis context: ChEMBL loader with parquet caching, Tox21 loader, substructure enrichment (chi-squared), property distributions, AnalysisService, 3 tools (12 tests) -- Prediction context: XGBoost adapter (train/predict with scale_pos_weight, AUROC/AUPRC/F1 metrics, feature importance), model store (joblib save/load/list), Chemprop adapter (guarded), scaffold split, Butina clustering, ensemble, PredictionService, 3 tools (20 tests) -- Simulation context: protein store (5 MRSA targets -- PBP2a, DHPS, DNA Gyrase, MurA, NDM-1), Vina adapter (guarded), RDKit-based ADMET prediction (Lipinski, mutagenic SMARTS alerts, hERG, BBB, hepatotoxicity), knowledge-based resistance assessment (7 mutations across 5 targets, compound class pattern matching), SimulationService, 3 tools (20 tests) +See [docs/roadmap/README.md](docs/roadmap/README.md) for the full phase-by-phase development history with dependency graph. diff --git a/CLAUDE.md b/CLAUDE.md index dc5c067..fe8b21d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,66 +2,40 @@ AGPL-3.0 | Claude Code Hackathon (Feb 10-16, 2026) -## Project Overview +Domain-agnostic scientific discovery platform using Claude as a hypothesis-driven reasoning engine. Pluggable `DomainConfig` + `DomainRegistry` system. Four domains: Molecular Science, Training Science, Nutrition Science, Impact Evaluation. -Ehrlich is a domain-agnostic scientific discovery platform that uses Claude as a hypothesis-driven scientific reasoning engine. Named after Paul Ehrlich, the father of the "magic bullet" concept. - -The engine is domain-agnostic: a pluggable `DomainConfig` + `DomainRegistry` system lets any scientific domain plug in with its own tools, score definitions, and prompt examples. Currently supports three domains: - -- **Molecular Science** -- antimicrobial resistance, drug discovery, toxicology, agricultural biocontrol -- **Training Science** -- exercise physiology, protocol optimization, injury risk assessment, clinical trials -- **Nutrition Science** -- supplement evidence analysis, dietary supplement safety, nutrient profiling, DRI adequacy assessment, drug-nutrient interactions, inflammatory index scoring +See `README.md` for setup, tools catalog (91 tools), data sources (28 APIs), API endpoints, and demo instructions. ## Architecture DDD monorepo: `server/` (Python 3.12) + `console/` (React 19 / TypeScript / Bun) + `web/` (TanStack Start landing page). -### Multi-Model Architecture (Director-Worker-Summarizer) +### Multi-Model (Director-Worker-Summarizer) ``` Opus 4.6 (Director) -- Formulates hypotheses, designs experiments, evaluates evidence, synthesizes (NO tools) -Sonnet 4.5 (Researcher) -- Executes experiments with 70 tools (parallel: 2 experiments per batch) +Sonnet 4.5 (Researcher) -- Executes experiments with 91 tools (parallel: 2 experiments per batch) Haiku 4.5 (Summarizer) -- Compresses large tool outputs (>2000 chars), PICO decomposition, evidence grading ``` Always uses `MultiModelOrchestrator`. Hypotheses tested in parallel batches of 2. -### Bounded Contexts (10) +### Bounded Contexts (11) | Context | Location | Purpose | |---------|----------|---------| | kernel | `server/src/ehrlich/kernel/` | Shared primitives (SMILES, Molecule, exceptions) | -| shared | `server/src/ehrlich/shared/` | Cross-cutting ports and value objects (ChemistryPort, Fingerprint, Conformer3D, MolecularDescriptors) | +| shared | `server/src/ehrlich/shared/` | Cross-cutting ports and value objects (ChemistryPort, Fingerprint, Conformer3D) | | literature | `server/src/ehrlich/literature/` | Paper search (Semantic Scholar), references | | chemistry | `server/src/ehrlich/chemistry/` | RDKit cheminformatics | -| analysis | `server/src/ehrlich/analysis/` | Dataset exploration (ChEMBL, PubChem, GtoPdb), enrichment | +| analysis | `server/src/ehrlich/analysis/` | Dataset exploration (ChEMBL, PubChem, GtoPdb), causal inference (DiD, PSM, RDD, SC) | | prediction | `server/src/ehrlich/prediction/` | ML models (Chemprop, XGBoost) | -| simulation | `server/src/ehrlich/simulation/` | Docking, ADMET, resistance, target discovery (RCSB PDB, UniProt, Open Targets), toxicity (EPA CompTox) | -| training | `server/src/ehrlich/training/` | Evidence analysis, protocol comparison, injury risk, training metrics, clinical trials (ClinicalTrials.gov), PubMed literature (E-utilities), exercise database (wger) | -| nutrition | `server/src/ehrlich/nutrition/` | Supplement evidence, supplement labels (NIH DSLD), nutrient data (USDA FoodData), supplement safety (OpenFDA CAERS), drug-nutrient interactions (RxNav), DRI adequacy, nutrient ratios, inflammatory index | +| simulation | `server/src/ehrlich/simulation/` | Docking, ADMET, resistance, targets (RCSB PDB, UniProt, Open Targets), toxicity (EPA CompTox) | +| training | `server/src/ehrlich/training/` | Evidence analysis, protocol comparison, injury risk, clinical trials, PubMed, exercises | +| nutrition | `server/src/ehrlich/nutrition/` | Supplement evidence, labels, nutrients, safety, interactions, adequacy, inflammatory index | +| impact | `server/src/ehrlich/impact/` | Economic indicators (World Bank, WHO GHO, FRED, Census, BLS, INEGI, Banxico), health (CDC WONDER), spending (USAspending), education (College Scorecard), housing (HUD), open data (data.gov, datos.gob.mx) | | investigation | `server/src/ehrlich/investigation/` | Multi-model orchestration + PostgreSQL persistence + domain registry + MCP bridge | -### External Data Sources (15 external + 1 internal) - -| Source | API | Purpose | Auth | -|--------|-----|---------|------| -| ChEMBL | `https://www.ebi.ac.uk/chembl/api/data` | Bioactivity data (any assay type: MIC, IC50, Ki, EC50, Kd) | None | -| Semantic Scholar | `https://api.semanticscholar.org/graph/v1` | Scientific paper search | None | -| RCSB PDB | `https://search.rcsb.org` + `https://data.rcsb.org` | Dynamic protein target discovery by organism/function | None | -| PubChem | `https://pubchem.ncbi.nlm.nih.gov/rest/pug` | Compound search by target/activity/similarity | None | -| EPA CompTox | `https://api-ccte.epa.gov` | Environmental toxicity, bioaccumulation, fate (1M+ chemicals) | Free API key | -| UniProt | `https://rest.uniprot.org` | Protein function, disease associations, GO terms, PDB cross-refs | None | -| Open Targets | `https://api.platform.opentargets.org` | Disease-target associations (GraphQL, scored evidence) | None | -| GtoPdb | `https://www.guidetopharmacology.org/services` | Expert-curated pharmacology (pKi, pIC50, receptor classification) | None | -| ClinicalTrials.gov | `https://clinicaltrials.gov/api/v2` | Registered clinical trials for exercise/training RCTs | None | -| PubMed | `https://eutils.ncbi.nlm.nih.gov/entrez/eutils` | Biomedical literature search with MeSH terms (E-utilities) | None | -| wger | `https://wger.de/api/v2` | Exercise database (muscles, equipment, categories) | None | -| NIH DSLD | `https://api.ods.od.nih.gov/dsld/v9` | Dietary supplement label database (ingredients, amounts) | None | -| USDA FoodData | `https://api.nal.usda.gov/fdc/v1` | Nutrient profiles for foods and supplements | API key | -| OpenFDA CAERS | `https://api.fda.gov/food/event.json` | Supplement adverse event reports (safety monitoring) | None | -| RxNav | `https://rxnav.nlm.nih.gov/REST` | Drug-supplement and drug-nutrient interactions (RxNorm) | None | -| Ehrlich tsvector | internal | Full-text search of past investigation findings (PostgreSQL GIN index) | None | - ### Dependency Rules (STRICT) 1. `domain/` has ZERO external deps -- pure Python only (dataclasses, ABC, typing) @@ -80,7 +54,6 @@ Always uses `MultiModelOrchestrator`. Hypotheses tested in parallel batches of 2 ```bash cd server uv sync --extra dev # Core + dev deps -# uv sync --extra all --extra dev # All deps (deep learning) uv run uvicorn ehrlich.api.app:create_app --factory --reload --port 8000 uv run pytest --cov=ehrlich --cov-report=term-missing # Tests uv run ruff check src/ tests/ # Lint @@ -88,10 +61,6 @@ uv run ruff format src/ tests/ # Format uv run mypy src/ehrlich/ # Type check ``` -Required env vars: `ANTHROPIC_API_KEY`, `EHRLICH_DATABASE_URL` (PostgreSQL), `EHRLICH_WORKOS_CLIENT_ID`, `EHRLICH_WORKOS_API_KEY`. - -Optional extras: `deeplearning` (chemprop), `all` (everything). - ### Console ```bash @@ -103,19 +72,14 @@ bun run build # vite build (generates route tree + bundles) bun run typecheck # tsc --noEmit (run after build for route types) ``` -Required env vars in `console/.env.local`: `VITE_API_URL` (e.g. `http://localhost:8000/api/v1`), `VITE_WORKOS_CLIENT_ID`, `VITE_WORKOS_REDIRECT_URI` (e.g. `http://localhost:5173/callback`). - -WorkOS dashboard must whitelist the console origin (e.g. `http://localhost:5173`) in **Authentication > Sessions > CORS**. - ### Web (Landing Page) ```bash cd web bun install -bun run dev # Dev server :3000 (vite dev) +bun run dev # Dev server :3000 bun run build # vite build (generates .output/) bun run typecheck # tsc --noEmit -bun run start # node .output/server/index.mjs ``` ## Quality Gates @@ -134,279 +98,108 @@ type(scope): description Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test` -Scopes: kernel, shared, literature, chemistry, analysis, prediction, simulation, training, nutrition, investigation, api, console, mol, data, ci, docs, infra - -## Tools (70 Total) - -### Chemistry (6) -- RDKit cheminformatics, domain-agnostic -- `validate_smiles` -- Validate SMILES string -- `compute_descriptors` -- MW, LogP, TPSA, HBD, HBA, QED, rings -- `compute_fingerprint` -- Morgan (2048-bit) or MACCS (166-bit) -- `tanimoto_similarity` -- Similarity between two molecules -- `generate_3d` -- 3D conformer with MMFF94 optimization -- `substructure_match` -- SMARTS/SMILES substructure search - -### Literature (3) -- Semantic Scholar -- `search_literature` -- Paper search (title, authors, year, DOI, abstract, citations) -- `search_citations` -- Citation chasing (snowballing) via references/citing papers -- `get_reference` -- Curated reference lookup by key or DOI - -### Analysis (6) -- ChEMBL + PubChem + GtoPdb -- `explore_dataset` -- Load ChEMBL bioactivity data for any organism/target -- `search_bioactivity` -- Flexible ChEMBL query (any assay type: MIC, Ki, EC50, IC50, Kd) -- `search_compounds` -- PubChem compound search by target/activity/similarity -- `search_pharmacology` -- GtoPdb curated receptor/ligand interactions with affinities -- `analyze_substructures` -- Chi-squared enrichment of SMARTS patterns -- `compute_properties` -- Property distributions (active vs inactive) - -### Prediction (6) -- XGBoost, domain-agnostic ML -- `train_model` -- Train ML model on SMILES+activity dataset (scaffold-split + random-split metrics, permutation p-value) -- `predict_candidates` -- Score compounds with trained model -- `cluster_compounds` -- Butina structural clustering -- `train_classifier` -- Train binary classifier on tabular feature data (any domain) -- `predict_scores` -- Score samples with trained classifier (any domain) -- `cluster_data` -- Hierarchical clustering on tabular feature data (any domain) - -### Simulation (7) -- Docking, ADMET, targets, toxicity, annotations -- `search_protein_targets` -- RCSB PDB search by organism/function/keyword -- `get_protein_annotation` -- UniProt protein function, disease links, GO terms -- `search_disease_targets` -- Open Targets disease-target associations (scored) -- `dock_against_target` -- Descriptor-based binding energy estimation -- `predict_admet` -- Drug-likeness profiling (absorption, metabolism, toxicity) -- `fetch_toxicity_profile` -- EPA CompTox environmental toxicity data -- `assess_resistance` -- Knowledge-based resistance mutation scoring - -### Training Science (11) -- Exercise physiology and sports medicine -- `search_training_literature` -- Semantic Scholar with training science context -- `analyze_training_evidence` -- Pooled effect sizes, heterogeneity, evidence grading (A-D) -- `compare_protocols` -- Evidence-weighted protocol comparison with composite scoring -- `assess_injury_risk` -- Knowledge-based injury risk scoring (sport, load, history, age) -- `compute_training_metrics` -- ACWR, monotony, strain, session RPE load -- `search_clinical_trials` -- ClinicalTrials.gov exercise/training RCT search -- `search_pubmed_training` -- PubMed E-utilities with MeSH term support -- `search_exercise_database` -- wger exercise database by muscle/equipment/category -- `compute_performance_model` -- Banister fitness-fatigue model (CTL/ATL/TSB) -- `compute_dose_response` -- Dose-response curve from dose-effect data points -- `plan_periodization` -- Evidence-based periodization planning (linear/undulating/block) - -### Nutrition Science (10) -- Supplement evidence, safety, adequacy, interactions -- `search_supplement_evidence` -- Supplement efficacy literature search -- `search_supplement_labels` -- NIH DSLD supplement product ingredient lookup -- `search_nutrient_data` -- USDA FoodData Central nutrient profiles -- `search_supplement_safety` -- OpenFDA CAERS adverse event reports for supplements -- `compare_nutrients` -- Side-by-side nutrient comparison across multiple foods -- `assess_nutrient_adequacy` -- DRI-based nutrient adequacy assessment (EAR/RDA/AI/UL) -- `check_intake_safety` -- Tolerable Upper Intake Level (UL) safety screening -- `check_interactions` -- Drug-supplement and drug-nutrient interaction screening (RxNav) -- `analyze_nutrient_ratios` -- Clinically relevant nutrient ratios (omega-6:3, Ca:Mg, Na:K, Zn:Cu, Ca:P, Fe:Cu) -- `compute_inflammatory_index` -- Simplified Dietary Inflammatory Index (DII) scoring - -### Visualization (12) -- Domain-specific interactive charts -- `render_binding_scatter` -- Scatter plot of compound binding affinities (Recharts) -- `render_admet_radar` -- Radar chart of ADMET/drug-likeness properties (Recharts) -- `render_training_timeline` -- Training load timeline with ACWR danger zones (Recharts) -- `render_muscle_heatmap` -- Anatomical body diagram with muscle activation/risk (Custom SVG) -- `render_forest_plot` -- Forest plot for meta-analysis results (Visx) -- `render_evidence_matrix` -- Hypothesis-by-evidence heatmap (Visx) -- `render_performance_chart` -- Banister fitness-fatigue performance chart (Recharts) -- `render_funnel_plot` -- Funnel plot for publication bias assessment (Visx) -- `render_dose_response` -- Dose-response curve with confidence intervals (Visx) -- `render_nutrient_comparison` -- Grouped bar chart comparing nutrient profiles across foods (Recharts) -- `render_nutrient_adequacy` -- Horizontal bar chart showing % RDA with MAR score (Recharts) -- `render_therapeutic_window` -- Range chart showing EAR/RDA/UL safety zones per nutrient (Visx) - -### Statistics (2) -- Domain-agnostic hypothesis testing (scipy.stats) -- `run_statistical_test` -- Compare two numeric groups (auto-selects t-test/Welch/Mann-Whitney, returns p-value, Cohen's d, 95% CI) -- `run_categorical_test` -- Test contingency tables (auto-selects Fisher's exact/chi-squared, returns p-value, odds ratio/Cramer's V) - -### Investigation (7) -- Hypothesis lifecycle + self-referential -- `propose_hypothesis` -- Register testable hypothesis -- `design_experiment` -- Plan experiment with tool sequence -- `evaluate_hypothesis` -- Assess outcome (supported/refuted/revised) -- `record_finding` -- Record finding linked to hypothesis + evidence_type -- `record_negative_control` -- Validate model with known-inactive compounds -- `search_prior_research` -- Search Ehrlich's own past findings via full-text search -- `conclude_investigation` -- Final summary with ranked candidates +Scopes: kernel, shared, literature, chemistry, analysis, prediction, simulation, training, nutrition, impact, investigation, api, console, mol, data, ci, docs, infra ## Key Patterns ### Scientific Methodology -- **Hypothesis-driven investigation loop**: formulate hypotheses (with predictions, criteria, scope, prior confidence) -> design structured experiment protocols -> execute tools with methodology guidance -> evaluate with scientific methodology -> revise/reject -> synthesize. See `docs/scientific-methodology.md` for full details -- **Hypothesis model**: Based on Popper (falsifiability), Platt (strong inference), Feynman (compute consequences), Bayesian updating. Each hypothesis carries: prediction, null_prediction, success_criteria, failure_criteria, scope, hypothesis_type, prior_confidence, certainty_of_evidence. Evaluation uses 8-tier evidence hierarchy, effect size thresholds, Bayesian prior-to-posterior updating, contradiction resolution, and convergence checks -- **Experiment protocols**: Based on Fisher (1935), Platt (1964), Cohen (1988), Saltelli (2008), OECD (2007), Tropsha (2010). Each experiment carries: independent_variable, dependent_variable, controls, confounders, analysis_plan, success_criteria, failure_criteria. Director designs with 5-principle methodology (VARIABLES, CONTROLS, CONFOUNDERS, ANALYSIS PLAN, SENSITIVITY). Researcher executes with 5-principle methodology (SENSITIVITY, APPLICABILITY DOMAIN, UNCERTAINTY, VERIFICATION, NEGATIVE RESULTS) -- **Statistical testing in experiments** -- Director plans statistical tests (test type, alpha, effect size) in experiment design via optional `statistical_test_plan`; Researcher executes `run_statistical_test` (continuous) or `run_categorical_test` (categorical) after gathering comparison data; results recorded as findings with evidence_type based on significance -- **Evidence-linked findings**: every finding references a `hypothesis_id` + `evidence_type` (supporting/contradicting/neutral) -- **Controls**: negative controls validate model predictions with known-inactive compounds (`NegativeControl` entity); positive controls validate pipeline with known-active compounds (`PositiveControl` entity, correctly_classified >= 0.5, quality: sufficient/marginal/insufficient) -- **Validation metrics**: Z'-factor assay quality (Zhang et al., 1999), permutation significance (Y-scrambling, p-value), scaffold-split vs random-split comparison; controls scored through trained models; `ValidationMetricsComputed` event -- **Literature survey**: PICO decomposition + domain classification merged into single Haiku call; structured search with citation chasing (snowballing); GRADE-adapted body-of-evidence grading via Haiku; AMSTAR-2-adapted self-assessment; `LiteratureSurveyCompleted` event carries PICO, search stats, grade -- **User-guided steering**: `HypothesisApprovalRequested` event pauses orchestrator after formulation; user approves/rejects via `POST /investigate/{id}/approve`; `REJECTED` status; 5-min auto-approve timeout -- **Synthesis**: GRADE certainty grading (5 downgrading + 3 upgrading domains), priority tiers (1-4), structured limitations taxonomy, knowledge gap analysis, follow-up experiment recommendations +- **Hypothesis-driven investigation loop**: formulate -> design experiment -> execute tools -> evaluate -> tree action (deepen/branch/prune) -> synthesize. See `docs/scientific-methodology.md` +- **Tree search**: `TreeManager` scores hypotheses (`prior_confidence * depth_discount * evidence_bonus`), selects top 2 for testing, processes Director's tree action (deepen=child at depth+1, branch=sibling at same depth, prune=REJECTED). `max_depth=3` prevents infinite recursion +- **Hypothesis model**: Popper + Platt + Feynman + Bayesian updating. Fields: prediction, null_prediction, success/failure_criteria, scope, hypothesis_type, prior_confidence, certainty_of_evidence. Evaluation: 8-tier evidence hierarchy, effect sizes, Bayesian updating, convergence checks +- **Experiment protocols**: Fisher/Platt/Cohen/Saltelli/OECD/Tropsha. Fields: independent/dependent_variable, controls, confounders, analysis_plan, success/failure_criteria. Director designs with 5 principles (VARIABLES, CONTROLS, CONFOUNDERS, ANALYSIS PLAN, SENSITIVITY) +- **Statistical testing**: Director plans tests in `statistical_test_plan`; Researcher executes `run_statistical_test`/`run_categorical_test`; findings recorded with evidence_type based on significance +- **Controls**: negative controls (known-inactive compounds), positive controls (correctly_classified >= 0.5) +- **Validation**: Z'-factor assay quality, permutation significance (Y-scrambling), scaffold-split vs random-split +- **Literature survey**: PICO decomposition + domain classification (Haiku); citation chasing; GRADE-adapted grading; AMSTAR-2 self-assessment +- **User-guided steering**: `HypothesisApprovalRequested` pauses orchestrator; user approves/rejects via `POST /investigate/{id}/approve`; 5-min auto-approve timeout +- **Synthesis**: GRADE certainty grading, priority tiers (1-4), structured limitations, knowledge gap analysis ### System Patterns -- Each bounded context has: `domain/`, `application/`, `infrastructure/`, `tools.py` -- Domain entities use `@dataclass` with `__post_init__` validation; repository interfaces are ABCs in `domain/repository.py`; infrastructure adapters implement those ABCs -- Tool functions in `tools.py` are the boundary between Claude and application services -- **Parallel researchers**: `_run_experiment_batch()` uses `asyncio.Queue` to merge events from 2 concurrent experiments; three-layer differentiation prevents duplicate work: (1) orthogonal hypothesis formulation via Director prompt, (2) sibling-aware experiment design with `` context, (3) researcher sibling context via `` tag -- **Context compaction**: `_build_prior_context()` compresses completed hypotheses into XML summary for Director -- **Prompt engineering**: XML-tagged instructions (``, ``, ``, ``), multishot examples (2 per Director prompt), tool usage examples for Researcher -- **ToolCache**: in-memory TTL-based caching for deterministic and API tool results -- **Event persistence**: all SSE events stored in PostgreSQL `events` table; completed investigations replay full timeline on page reload -- **CostTracker**: per-model token usage with tiered pricing; `CostUpdate` event yields cost snapshots after each phase/batch -- **Structured outputs**: Director calls use `output_config` with JSON schemas (6 schemas in `domain/schemas.py`) to guarantee valid JSON; eliminates parsing fallbacks -- **Director streaming**: `_director_call()` is an async generator using `stream_message()`; yields `Thinking` events in real time as tokens arrive; Researcher and Summarizer remain non-streaming -- All external API clients follow same pattern: `httpx.AsyncClient`, retry with exponential backoff, structured error handling -- **Authentication**: WorkOS JWT middleware (`api/auth.py`); JWKS verification via `PyJWKClient`; `get_current_user` (header) and `get_current_user_sse` (header or `?token=` query param for EventSource); `get_optional_user` for public routes -- **Credit system**: director tier selection (haiku=1cr, sonnet=3cr, opus=5cr); credits deducted on investigation start; refunded on failure; `credit_transactions` table for audit trail; `GET /credits/balance` endpoint -- **BYOK (Bring Your Own Key)**: `X-Anthropic-Key` header pass-through; bypasses credit system; API key forwarded to `AnthropicClientAdapter`; checked on both `POST /investigate` and `GET /investigate/{id}/stream` +- Each bounded context: `domain/`, `application/`, `infrastructure/`, `tools.py` +- Domain entities: `@dataclass` with `__post_init__` validation; repositories: ABCs in `domain/repository.py` +- `tools.py` is the boundary between Claude and application services +- **Parallel researchers**: `run_experiment_batch()` uses `asyncio.Queue` for 2 concurrent experiments; three-layer differentiation prevents duplicates +- **Tree manager**: `TreeManager` in `application/tree_manager.py`; `select_next()` picks batch, `apply_evaluation()` processes Director action, `compute_branch_score()` scores hypotheses +- **Context compaction**: `_build_prior_context()` compresses completed hypotheses for Director +- **Prompt engineering**: XML-tagged instructions, multishot examples (2 per Director prompt), tool usage examples for Researcher +- **ToolCache**: in-memory TTL cache (deterministic: forever, API: 24h-7d) +- **Token optimization**: `token-efficient-tools-2025-02-19` beta header on all API calls; literature dedup by DOI across queries in `ToolDispatcher`; authors truncated to 3 in search results; default 5 papers per query (was 10) +- **Event persistence**: PostgreSQL `events` table; completed investigations replay full timeline +- **Structured outputs**: Director uses `output_config` with 6 JSON schemas (`domain/schemas.py`) +- **Director streaming**: `_director_call()` async generator with `stream_message()`; Researcher/Summarizer non-streaming +- External API clients: `httpx.AsyncClient`, retry with exponential backoff +- **Auth**: WorkOS JWT middleware; JWKS verification; header + query param auth for SSE; generic error messages (no token details leaked) +- **Credits**: director tier (haiku=1, sonnet=3, opus=5); deducted on start, refunded on failure +- **BYOK**: `X-Anthropic-Key` header pass-through; bypasses credits; keys redacted from error logs via `_sanitize_error()` +- **Document upload**: CSV/XLSX/PDF via `POST /upload`; `FileProcessor` -> `UploadedFile`; magic byte validation; owner-scoped with 30-min TTL; injected as `` XML with `html.escape()` sanitization + +### Security Patterns + +- **Security headers**: `SecurityHeadersMiddleware` in `app.py` — `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: geolocation=(), microphone=(), camera=()` +- **CORS hardening**: explicit `allow_methods` and `allow_headers` (no wildcards); wildcard origin warning in non-development environments; `EHRLICH_ENVIRONMENT` setting in `config.py` +- **Upload isolation**: `_pending_uploads` stores `(workos_id, UploadedFile)` tuples; ownership validated on claim; 30-min TTL auto-expiry +- **Prompt injection defense**: all user-controlled content in `` XML escaped via `html.escape()` — filenames, column names, dtypes, stats, sample values, document excerpts +- **Input validation**: `InvestigateRequest.prompt` min_length=10, max_length=10000; `director_tier` regex pattern; SMILES max_length=500; PDB ID format validation; URL path encoding for external API clients +- **Markdown XSS**: `rehype-sanitize` on all `` components (InvestigationReport, InvestigationComparison, paper view) +- **BYOK key storage**: `sessionStorage` (tab-scoped, cleared on close) instead of `localStorage` +- **Error sanitization**: API key patterns (`sk-ant-*`) redacted from Anthropic client error logs; tool errors don't expose internal details; JWT errors return generic messages +- **File content validation**: magic byte checks (PDF: `%PDF-`, XLSX: `PK\x03\x04`, CSV: no null bytes + valid UTF-8) +- **SQL injection prevention**: `db_name` validated against `^[a-zA-Z0-9_]+$` before interpolation in `_ensure_database()` +- **Approval state guard**: `POST /investigate/{id}/approve` returns 409 if orchestrator is not awaiting approval +- **SSE reconnection**: fresh `buildSSEUrl()` call on each reconnect to refresh auth token ### Integration Patterns -- **Domain configuration**: `DomainConfig` defines per-domain tool tags, score definitions, prompt examples; `DomainRegistry` auto-detects domain; `DomainDetected` SSE event sends display config to frontend -- **Domain tool examples**: each `DomainConfig` includes `tool_examples` in `experiment_examples` showing realistic tool chaining patterns; ensures Researcher (Sonnet 4.5) has usage guidance for all domain-specific tools -- **Multi-domain investigations**: `DomainRegistry.detect()` returns `list[DomainConfig]`; `merge_domain_configs()` creates synthetic merged config with union of tool_tags, concatenated score_definitions, joined prompt examples -- **Tool tagging**: Tools tagged with frozenset domain tags (chemistry, analysis, prediction, simulation, training, clinical, nutrition, safety, literature, visualization); investigation control tools are universal (no tags); researcher sees only domain-relevant tools -- **Self-referential research**: `search_prior_research` queries PostgreSQL `tsvector` + GIN index of past findings; indexed on completion; intercepted in orchestrator `_dispatch_tool()` and routed to repository; "ehrlich" source type links to past investigations -- **MCP bridge**: Optional `MCPBridge` connects to external MCP servers (e.g. Excalidraw); tools registered dynamically via `ToolRegistry.register_mcp_tools()`; lifecycle managed by orchestrator; enabled via `EHRLICH_MCP_EXCALIDRAW=true` -- **SSE streaming**: 20 event types for real-time updates; reconnection with exponential backoff (1s, 2s, 4s, max 3 retries) -- **Phase progress**: `PhaseChanged` event tracks 6 orchestrator phases (Classification & PICO -> Literature Survey -> Formulation -> Hypothesis Testing -> Negative Controls -> Synthesis); frontend renders 6-segment progress bar -- **Citation provenance**: `source_type` + `source_id` on findings, rendered as clickable badges linking to ChEMBL, PDB, DOI, PubChem, UniProt, Open Targets -- **Data configuration**: protein targets via YAML (`data/targets/*.yaml`) + dynamic RCSB PDB discovery; resistance data via YAML (`data/resistance/*.yaml`) -- **Flexible data loading**: ChEMBL queries accept any assay type (MIC, Ki, EC50, etc.), not hardcoded +- **Domain config**: `DomainConfig` with tool_tags, score_definitions, prompt examples; `DomainRegistry` auto-detects; `DomainDetected` SSE event +- **Multi-domain**: `merge_domain_configs()` creates synthetic config from multiple domains +- **Tool tagging**: frozenset domain tags; investigation tools are universal; researcher sees only domain-relevant tools +- **Self-referential**: `search_prior_research` queries PostgreSQL tsvector + GIN index +- **MCP bridge**: Optional external MCP servers; dynamic tool registration; `EHRLICH_MCP_EXCALIDRAW=true` +- **SSE**: 21 event types; reconnection with exponential backoff (1s, 2s, 4s, max 3 retries) +- **Phase progress**: 6 phases (Classification & PICO -> Literature Survey -> Formulation -> Hypothesis Testing -> Negative Controls -> Synthesis) +- **Citation provenance**: `source_type` + `source_id` on findings (ChEMBL, PDB, DOI, PubChem, UniProt, Open Targets) ### UI Patterns -- **Visualization pipeline**: 12 viz tools return `VisualizationPayload` JSON; orchestrator intercepts via `_maybe_viz_event()` and emits `VisualizationRendered` SSE event; `VizRegistry` maps `viz_type` to lazy-loaded React component; `VisualizationPanel` renders all charts -- **LiveLabViewer**: 3Dmol.js scene driven by SSE events -- protein targets load, ligands dock, candidates color by score. SSE event mapping: `dock_against_target` -> ligand in binding pocket, `predict_candidates` -> color by probability, `completed` -> top candidates glow. Interactive rotate/zoom/click, split-pane with Timeline -- **React Flow diagrams**: `@xyflow/react` node graph with custom `InvestigationNode` and `AnnotationNode` types; status-colored nodes (proposed=gray, testing=blue, supported=green, refuted=red, revised=orange); dashed edges for revisions, solid smoothstep for links; read-only with zoom/pan/minimap; lazy-loaded (~188KB chunk) -- **Investigation report**: 8 sections (Research Question, Executive Summary, Hypotheses & Outcomes, Methodology, Key Findings, Candidate Molecules, Model Validation, Cost & Performance); shown post-completion with full audit trail; markdown export via client-side generation -- **Molecule visualization**: server-side 2D SVG (RDKit `rdMolDraw2D`), 3Dmol.js for 3D/docking; `CandidateTable` shows thumbnails with expandable detail (3D viewer + properties + Lipinski badge) -- **Molecule API**: `/molecule/depict` (SVG, cached 24h), `/molecule/conformer`, `/molecule/descriptors`, `/molecule/targets` -- **Investigation templates**: 7 cross-domain prompts (4 molecular + 2 training + 1 nutrition) on home page via `TemplateCards` with domain badges -- **Candidate comparison**: side-by-side scoring view for 2-4 candidates with best-in-group highlighting -- **HypothesisBoard**: kanban-style card grid showing hypothesis status with expandable confidence bars -- **Hypothesis approval**: `HypothesisApprovalCard` renders in main content area (not status bar) for maximum prominence during approval gate -- **Home page**: hero section with stats, collapsible BYOK toggle, SectionHeader-driven sections, empty state for new users -- **Design system**: OKLCH hue 155 (green) brand primary; `rounded-sm` buttons, `rounded-md` cards; `hover:opacity-90` (no glow); Space Grotesk + JetBrains Mono fonts; shared `SectionHeader` component for consistent section headings; VIZ_COLORS aligned to hue 155 -- **Layout**: conditional `hideHeader` on AppLayout for investigation/compare pages (own header); consistent `max-w-[1200px]` across all pages +- **Viz pipeline**: 17 viz tools -> `VisualizationPayload` -> `VisualizationRendered` SSE -> `VizRegistry` lazy-loads React component +- **LiveLabViewer**: 3Dmol.js SSE-driven scene (protein load, ligand dock, candidate score coloring) +- **React Flow**: `@xyflow/react` with custom node types; status-colored (proposed=gray, testing=blue, supported=green, refuted=red, revised=amber, rejected=dimmed gray); tree depth + parent-child revision edges +- **Design system**: OKLCH hue 155 (green); `rounded-sm` buttons, `rounded-md` cards; Space Grotesk + JetBrains Mono +- **Layout**: conditional `hideHeader` for investigation/compare pages; `max-w-[1200px]` - TanStack Router file-based routing; `ErrorBoundary` wraps LiveLabViewer and InvestigationDiagram -- Toast notifications via `sonner` (dark-themed OKLCH colors); custom scrollbar CSS (8px webkit + Firefox thin) -- `InvestigationCompleted` event carries `findings[]`, `hypotheses[]`, `negative_controls[]`, `prompt` for replay hydration ## Key Files (Server) All paths relative to `server/src/ehrlich/`. -### shared/ - -| File | Purpose | -|------|---------| -| `chemistry_port.py` | `ChemistryPort` ABC -- cross-cutting interface for chemistry operations | -| `fingerprint.py` | `Fingerprint` value object | -| `descriptors.py` | `MolecularDescriptors` value object | -| `conformer.py` | `Conformer3D` value object | - -### literature/ - -| File | Purpose | -|------|---------| -| `infrastructure/semantic_scholar.py` | Semantic Scholar paper search with retry | - -### chemistry/ - -| File | Purpose | -|------|---------| -| `infrastructure/rdkit_adapter.py` | RDKit adapter including `depict_2d` (SVG) | -| `application/chemistry_service.py` | Thin wrapper over adapter | - -### analysis/ - -| File | Purpose | -|------|---------| -| `domain/statistics.py` | `StatisticalResult` frozen dataclass | -| `application/statistics_service.py` | `StatisticsService` -- scipy.stats hypothesis testing (t-test, Welch, Mann-Whitney, chi-squared, Fisher) | -| `infrastructure/chembl_loader.py` | ChEMBL bioactivity loader (flexible assay types) | -| `infrastructure/pubchem_client.py` | PubChem PUG REST compound search | -| `infrastructure/gtopdb_client.py` | GtoPdb REST client for curated pharmacology | - -### prediction/ - -| File | Purpose | -|------|---------| -| `domain/ports.py` | `FeatureExtractor`, `DataSplitter`, `Clusterer` ABCs (domain-agnostic ML ports) | -| `domain/prediction_result.py` | `PredictionResult` frozen dataclass (generic `identifier: str`) | -| `application/prediction_service.py` | Generic ML service: train, predict, cluster via injected ports | -| `infrastructure/molecular_adapters.py` | `MolecularFeatureExtractor`, `ScaffoldSplitter`, `MolecularClusterer` (RDKit) | -| `infrastructure/generic_adapters.py` | `RandomSplitter`, `DistanceClusterer` (scipy hierarchical) | -| `infrastructure/xgboost_adapter.py` | XGBoost training, prediction, permutation test | -| `tools.py` | 6 prediction tools (3 molecular + 3 generic) | - -### simulation/ - | File | Purpose | |------|---------| -| `infrastructure/rcsb_client.py` | RCSB PDB Search + Data API for target discovery | -| `infrastructure/comptox_client.py` | EPA CompTox CTX API for environmental toxicity | -| `infrastructure/protein_store.py` | YAML-based protein target store + RCSB PDB fallback | -| `infrastructure/uniprot_client.py` | UniProt REST client for protein annotations | -| `infrastructure/opentargets_client.py` | Open Targets GraphQL client for disease-target associations | - -### training/ - -| File | Purpose | -|------|---------| -| `tools.py` | 10 training science tools | -| `domain/entities.py` | EvidenceGrade, StudyResult, EvidenceAnalysis, TrainingProtocol, ProtocolComparison, InjuryRiskAssessment, WorkloadMetrics, ClinicalTrial, PubMedArticle, Exercise, PerformanceModelPoint, DoseResponsePoint | -| `domain/repository.py` | ClinicalTrialRepository, PubMedRepository, ExerciseRepository ABCs | -| `application/training_service.py` | TrainingService (evidence analysis, protocol comparison, injury risk, metrics, clinical trials, PubMed, exercises, performance model, dose-response) | -| `infrastructure/clinicaltrials_client.py` | ClinicalTrials.gov v2 API | -| `infrastructure/pubmed_client.py` | PubMed E-utilities API (ESearch + EFetch) | -| `infrastructure/wger_client.py` | wger exercise database REST API | - -### nutrition/ - -| File | Purpose | -|------|---------| -| `tools.py` | 10 nutrition science tools | -| `domain/entities.py` | IngredientEntry, SupplementLabel, NutrientEntry, NutrientProfile, AdverseEvent, DrugInteraction, NutrientRatio, AdequacyResult | -| `domain/repository.py` | SupplementRepository, NutrientRepository, AdverseEventRepository, InteractionRepository ABCs | -| `domain/dri.py` | Dietary Reference Intake (DRI) tables -- EAR, RDA, AI, UL for ~30 nutrients by age/sex | -| `application/nutrition_service.py` | NutritionService (supplement evidence, labels, nutrients, safety, comparison, adequacy, interactions, ratios, inflammatory index) | -| `infrastructure/dsld_client.py` | NIH DSLD supplement label database | -| `infrastructure/fooddata_client.py` | USDA FoodData Central nutrient profiles | -| `infrastructure/openfda_client.py` | OpenFDA CAERS adverse event reporting | -| `infrastructure/rxnav_client.py` | RxNav drug-nutrient interaction API | - -### investigation/ - -| File | Purpose | -|------|---------| -| `application/multi_orchestrator.py` | Director-Worker-Summarizer orchestrator | -| `application/cost_tracker.py` | Per-model cost tracking with tiered pricing | -| `application/tool_cache.py` | In-memory TTL cache for tool results | -| `application/tool_registry.py` | `ToolRegistry` with domain tag filtering | -| `application/prompts.py` | Domain-adaptive prompts (PICO, literature survey, director, researcher, summarizer) | -| `domain/schemas.py` | JSON schemas for structured output responses (6 schemas: PICO, Formulation, Experiment Design, Evaluation, Synthesis, Literature Grading) | -| `domain/hypothesis.py` | Hypothesis entity + HypothesisStatus + HypothesisType enums | -| `domain/experiment.py` | Experiment entity + ExperimentStatus enum | -| `domain/candidate.py` | Candidate dataclass (identifier, scores, attributes) | -| `domain/negative_control.py` | NegativeControl frozen dataclass | -| `domain/positive_control.py` | PositiveControl frozen dataclass | -| `domain/validation.py` | Z'-factor assay quality metric | -| `domain/events.py` | 20 domain events | -| `domain/visualization.py` | `VisualizationPayload` frozen dataclass | -| `domain/repository.py` | InvestigationRepository ABC | -| `domain/domain_config.py` | `DomainConfig` + `ScoreDefinition` + `merge_domain_configs()` | -| `domain/domain_registry.py` | `DomainRegistry` (register, multi-detect, lookup) | -| `domain/domains/molecular.py` | `MOLECULAR_SCIENCE` config | -| `domain/domains/training.py` | `TRAINING_SCIENCE` config | -| `domain/domains/nutrition.py` | `NUTRITION_SCIENCE` config | -| `domain/mcp_config.py` | `MCPServerConfig` frozen dataclass | -| `tools_viz.py` | 12 visualization tools | -| `infrastructure/repository.py` | PostgreSQL persistence (asyncpg) + tsvector findings search + user/credit management | -| `infrastructure/anthropic_client.py` | Anthropic API adapter with retry, streaming, and structured outputs | -| `infrastructure/mcp_bridge.py` | MCP client bridge (stdio/SSE/streamable_http) | +| `shared/chemistry_port.py` | `ChemistryPort` ABC | +| `chemistry/infrastructure/rdkit_adapter.py` | RDKit adapter (only RDKit import point) | +| `analysis/application/causal_service.py` | DiD, PSM, RDD, Synthetic Control, threat assessment, cost-effectiveness | +| `analysis/domain/causal_ports.py` | Causal estimator port ABCs | +| `analysis/domain/evidence_standards.py` | WWC tiers, CONEVAL MIR, CREMAA criteria | +| `prediction/application/prediction_service.py` | Generic ML: train, predict, cluster via injected ports | +| `prediction/domain/ports.py` | `FeatureExtractor`, `DataSplitter`, `Clusterer` ABCs | +| `training/application/training_service.py` | Evidence, protocols, injury risk, metrics, clinical trials, PubMed, exercises | +| `nutrition/application/nutrition_service.py` | Supplements, labels, nutrients, safety, adequacy, interactions, ratios | +| `nutrition/domain/dri.py` | DRI tables (EAR, RDA, AI, UL by age/sex) | +| `impact/application/impact_service.py` | Economic indicators, benchmarks, program comparison, spending, education, housing, open data, Mexico INEGI/Banxico/datos.gob.mx, CREMAA/MIR analysis | +| `investigation/application/multi_orchestrator.py` | Director-Worker-Summarizer orchestrator (main 6-phase loop) | +| `investigation/application/tool_dispatcher.py` | Tool execution with caching, `search_prior_research` and `query_uploaded_data` interception | +| `investigation/application/tree_manager.py` | Hypothesis tree exploration: scoring, selection, deepen/branch/prune actions | +| `investigation/application/researcher_executor.py` | Single researcher experiment executor | +| `investigation/application/batch_executor.py` | Parallel batch executor (2 concurrent) | +| `investigation/application/paper_generator.py` | Scientific paper generation from investigation data + events | +| `investigation/application/prompts.py` | All domain-adaptive prompts | +| `investigation/domain/schemas.py` | 6 JSON schemas for structured outputs | +| `investigation/domain/domain_config.py` | `DomainConfig` + `ScoreDefinition` + `merge_domain_configs()` | +| `investigation/domain/domain_registry.py` | `DomainRegistry` (register, multi-detect, lookup) | +| `investigation/domain/domains/*.py` | Per-domain configs (molecular, training, nutrition, impact) | +| `investigation/infrastructure/repository.py` | PostgreSQL persistence + tsvector search + credits + uploads | +| `investigation/infrastructure/anthropic_client.py` | Anthropic API adapter (retry, streaming, structured outputs) | ## Key Files (API) @@ -414,89 +207,36 @@ All paths relative to `server/src/ehrlich/api/`. | File | Purpose | |------|---------| -| `auth.py` | WorkOS JWT middleware (JWKS verification, header + query param auth for SSE) | -| `routes/investigation.py` | REST + SSE endpoints, 70-tool registry, domain registry, MCP bridge, credit system, BYOK | -| `routes/methodology.py` | GET /methodology: phases, domains, tools, data sources, models | -| `routes/molecule.py` | Molecule depiction, conformer, descriptors, targets endpoints | -| `routes/stats.py` | GET /stats: aggregate counts | -| `sse.py` | Domain event to SSE conversion (20 types) | +| `auth.py` | WorkOS JWT middleware (JWKS, header + query param) | +| `routes/investigation.py` | REST + SSE + paper export, 91-tool registry, domain registry, credits, BYOK | +| `routes/upload.py` | File upload (CSV/XLSX/PDF) | +| `routes/molecule.py` | Molecule depiction, conformer, descriptors, targets | +| `sse.py` | Domain event to SSE conversion (21 types) | ## Key Files (Console) All paths relative to `console/src/`. -### Investigation components - | File | Purpose | |------|---------| | `features/investigation/components/LiveLabViewer.tsx` | 3Dmol.js SSE-driven molecular scene | | `features/investigation/components/InvestigationDiagram.tsx` | Lazy-loaded React Flow wrapper | -| `features/investigation/components/DiagramRenderer.tsx` | React Flow renderer with custom node types, minimap, controls | | `features/investigation/components/InvestigationReport.tsx` | 8-section structured report | | `features/investigation/components/CandidateTable.tsx` | Thumbnail grid with expandable rows | -| `features/investigation/components/CandidateDetail.tsx` | 2D + 3D views + property card + Lipinski badge | -| `features/investigation/components/CandidateComparison.tsx` | Side-by-side scoring comparison | -| `features/investigation/components/HypothesisBoard.tsx` | Kanban-style hypothesis status grid | -| `features/investigation/components/HypothesisCard.tsx` | Expandable hypothesis card with confidence bar | +| `features/investigation/components/HypothesisBoard.tsx` | Kanban hypothesis status grid | | `features/investigation/components/HypothesisApprovalCard.tsx` | Approve/reject before testing | -| `features/investigation/components/ActiveExperimentCard.tsx` | Live experiment activity card | -| `features/investigation/components/CompletionSummaryCard.tsx` | Post-completion summary card with expandable cost breakdown | -| `features/investigation/components/NegativeControlPanel.tsx` | Negative control validation table | -| `features/investigation/components/TemplateCards.tsx` | 7 cross-domain research prompt templates | -| `features/investigation/components/PromptInput.tsx` | Controlled prompt input with director tier selector pills | -| `features/investigation/components/BYOKSettings.tsx` | API key management for BYOK users | - -### Investigation lib + hooks - -| File | Purpose | -|------|---------| +| `features/investigation/components/PromptInput.tsx` | Prompt input with director tier selector + file upload | +| `features/investigation/components/ThreatAssessment.tsx` | Validity threat panel with severity badges | +| `features/investigation/components/FileUpload.tsx` | Drag-and-drop upload (CSV/XLSX/PDF) | | `features/investigation/lib/scene-builder.ts` | SSE events to 3Dmol.js operations | -| `features/investigation/lib/diagram-builder.ts` | Hypotheses/experiments/findings to React Flow nodes + edges | -| `features/investigation/lib/export-markdown.ts` | Client-side markdown report generation | -| `features/investigation/hooks/use-methodology.ts` | React hook for methodology data | -| `features/investigation/hooks/use-credits.ts` | Credit balance hook (TanStack Query) | - -### Molecule components - -| File | Purpose | -|------|---------| -| `features/molecule/components/MolViewer2D.tsx` | Server-side SVG viewer | -| `features/molecule/components/MolViewer3D.tsx` | 3Dmol.js WebGL conformer viewer | -| `features/molecule/components/DockingViewer.tsx` | 3Dmol.js protein+ligand overlay | - -### Visualization system - -| File | Purpose | -|------|---------| -| `features/visualization/VizRegistry.tsx` | Lazy-loaded component registry (viz_type to React component) | -| `features/visualization/VisualizationPanel.tsx` | Grid layout for multiple visualizations | -| `features/visualization/theme.ts` | OKLCH color tokens for charts | -| `features/visualization/charts/BindingScatter.tsx` | Recharts ScatterChart | -| `features/visualization/charts/ADMETRadar.tsx` | Recharts RadarChart | -| `features/visualization/charts/TrainingTimeline.tsx` | Recharts ComposedChart with ACWR + Brush | -| `features/visualization/charts/ForestPlot.tsx` | Visx custom forest plot | -| `features/visualization/charts/EvidenceMatrix.tsx` | Visx HeatmapRect with OKLCH scale | -| `features/visualization/charts/PerformanceChart.tsx` | Recharts ComposedChart for Banister fitness-fatigue model | -| `features/visualization/charts/FunnelPlot.tsx` | Visx funnel plot for publication bias assessment | -| `features/visualization/charts/DoseResponseCurve.tsx` | Visx dose-response curve with confidence band | -| `features/visualization/charts/NutrientComparison.tsx` | Recharts grouped BarChart for food nutrient comparison | -| `features/visualization/charts/NutrientAdequacy.tsx` | Recharts horizontal BarChart showing % RDA with MAR score | -| `features/visualization/charts/TherapeuticWindow.tsx` | Visx custom range chart with EAR/RDA/UL safety zones | -| `features/visualization/anatomy/BodyDiagram.tsx` | Custom SVG anatomical body diagram | -| `features/visualization/anatomy/body-paths.ts` | SVG path data for front/back views | -| `features/visualization/anatomy/color-scale.ts` | OKLCH intensity color interpolation | - -### Shared + routes - -| File | Purpose | -|------|---------| -| `shared/hooks/use-auth.ts` | WorkOS auth hook wrapper (AuthKitProvider integration) | -| `shared/lib/api.ts` | Authenticated fetch with BYOK `X-Anthropic-Key` header support | -| `shared/components/ErrorBoundary.tsx` | Error boundary for LiveLabViewer and InvestigationDiagram | -| `shared/components/ui/SectionHeader.tsx` | Shared section header with icon, title, description (border-l-2 accent) | -| `shared/components/ui/Toaster.tsx` | Sonner toast wrapper with dark OKLCH theme | -| `routes/methodology.tsx` | Methodology page | -| `routes/callback.tsx` | WorkOS OAuth callback (waits for auth, then redirects home) | +| `features/investigation/lib/diagram-builder.ts` | Hypotheses/experiments/findings to React Flow nodes | +| `features/visualization/VizRegistry.tsx` | Lazy-loaded chart component registry | +| `features/visualization/VisualizationPanel.tsx` | Grid layout for multiple charts | +| `features/visualization/theme.ts` | OKLCH color tokens | +| `routes/paper.$id.tsx` | Print-optimized paper view with PDF export | +| `styles/print.css` | `@media print` stylesheet for PDF export | +| `shared/hooks/use-auth.ts` | WorkOS auth wrapper | +| `shared/lib/api.ts` | Authenticated fetch with BYOK support | ## Key Files (Web Landing Page) @@ -504,26 +244,9 @@ All paths relative to `web/src/`. | File | Purpose | |------|---------| -| `routes/__root.tsx` | Root layout with shellComponent, HeadContent, SEO meta, self-hosted font preloads | -| `routes/index.tsx` | Landing page with lazy-loaded components (React.lazy + Suspense) | -| `components/Nav.tsx` | Fixed navbar with scroll progress bar + mobile menu | -| `components/Footer.tsx` | Minimal footer with links and license | -| `components/SectionHeader.tsx` | Mono label with left border accent | -| `components/Hero.tsx` | Bottom-third hero with MolecularNetwork, stats badges, CTAs | -| `components/MolecularNetwork.tsx` | 3D rotating node graph (Canvas 2D, mouse repulsion, CSS mask edge fade) | -| `components/HowItWorks.tsx` | 6-phase methodology timeline with vertical connecting line | -| `components/ConsolePreview.tsx` | Browser-frame mockups (timeline, hypothesis board, candidates, radar) | -| `components/Architecture.tsx` | Director-Worker-Summarizer model cards, dot grid bg, amber glow | -| `components/Domains.tsx` | 3 domain cards with tool counts, multi-domain callout | -| `components/Visualizations.tsx` | 4 visualization category cards with tech labels | -| `components/DataSources.tsx` | 16 source cards, surface bg with teal glow | -| `components/WhoItsFor.tsx` | 3 persona cards (Student, Academic, Industry) | -| `components/Differentiators.tsx` | 3 differentiator cards with capabilities lists | -| `components/OpenSource.tsx` | Typography-driven section with code snippet + licensing | -| `components/Roadmap.tsx` | Planned domains + platform features (dashed border cards) | -| `components/CTA.tsx` | Pricing tiers, terminal quickstart, primary glow accent | -| `styles/app.css` | Tailwind 4 + OKLCH tokens + @font-face declarations + scroll/stagger/terminal animations | -| `public/fonts/*.woff2` | Self-hosted fonts (6 files: Space Grotesk 400-700, JetBrains Mono 400-500) | -| `lib/constants.ts` | Stats, nav/footer links, domains, data sources, methodology phases | -| `lib/use-reveal.ts` | IntersectionObserver scroll reveal hook | -| `lib/use-scroll-progress.ts` | Scroll progress fraction (0-1) hook | +| `routes/__root.tsx` | Root layout, SEO meta, self-hosted font preloads | +| `routes/index.tsx` | Landing page with lazy-loaded sections | +| `components/Hero.tsx` | Hero with MolecularNetwork, stats, CTAs | +| `components/MolecularNetwork.tsx` | 3D rotating node graph (Canvas 2D) | +| `styles/app.css` | Tailwind 4 + OKLCH tokens + animations | +| `lib/constants.ts` | Stats, links, domains, data sources | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a161d88..5002853 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,117 +2,39 @@ Ehrlich is an AI-powered scientific discovery engine that uses Claude as a hypothesis-driven reasoning engine. The core engine is domain-agnostic -- any scientific domain can plug in with its own tools, scoring, and visualization. -See [docs/domain-agnostic.md](docs/domain-agnostic.md) for the full story of how and why the engine was generalized. +See [README.md](README.md) for setup, architecture overview, and development commands. +See [CLAUDE.md](CLAUDE.md) for dependency rules, key patterns, and key files. -## Prerequisites +## Contributor License Agreement -- **Python 3.12+** with [uv](https://docs.astral.sh/uv/) package manager -- **Bun** (or Node 20+) for the console -- **ANTHROPIC_API_KEY** environment variable set +By submitting a pull request to this repository, you agree to the following terms: -## Setup +1. **You grant Ehrlich's maintainers a perpetual, worldwide, non-exclusive, royalty-free license** to use, reproduce, modify, distribute, sublicense, and relicense your contributions as part of this project. -### Server +2. **You retain copyright** to your contributions. This agreement does not transfer ownership. -```bash -cd server -uv sync --extra dev -uv run uvicorn ehrlich.api.app:create_app --factory --reload --port 8000 -``` - -### Console - -```bash -cd console -bun install -bun dev # Dev server at :5173 -``` +3. **You have the right to submit** the contribution. If your contribution includes code from your employer or third parties, you have obtained the necessary permissions. -## Development Workflow +4. **Your contribution is provided "as is"** without warranty of any kind. -### Quality Gates (all must pass before commit) +### Why This Matters -**Server:** -```bash -uv run ruff check src/ tests/ # Lint (zero violations) -uv run ruff format src/ tests/ --check # Format check -uv run mypy src/ehrlich/ # Type check (strict) -uv run pytest --cov=ehrlich # Tests (80%+ coverage) -``` - -**Console:** -```bash -bun run build # Vite build (generates route tree) -bun run typecheck # tsc --noEmit (run after build) -bunx vitest run # Tests -``` - -### Commit Format - -``` -type(scope): description -``` +Ehrlich is currently licensed under AGPL-3.0. This CLA preserves the option to change the license in the future (e.g., to MIT) without requiring permission from every past contributor. This is standard practice for open source projects with dual-licensing strategies. -**Types:** `feat`, `fix`, `refactor`, `chore`, `docs`, `test` +If you have questions about this CLA, open an issue before contributing. -**Scopes:** `kernel`, `shared`, `literature`, `chemistry`, `analysis`, `prediction`, `simulation`, `training`, `nutrition`, `investigation`, `api`, `console`, `mol`, `data` - -### Code Standards (Non-Negotiable) +--- -Every contribution must respect: +## Code Standards (Non-Negotiable) -- **DDD + Clean Architecture** -- Respect bounded contexts. `domain/` is pure Python with zero external deps. `application/` depends on domain interfaces, never infrastructure. `infrastructure/` implements domain ABCs. No cross-context domain imports. -- **No redundancy** -- DRY across layers. If a pattern exists, reuse it. If a utility exists, find it before writing a new one. Three similar lines of code is better than a premature abstraction, but true duplication is never acceptable. -- **No dead code** -- No commented-out blocks, no unused imports, no orphaned functions. Delete what you don't need. -- **No boilerplate** -- If it can be derived (schemas from type hints, routes from file conventions), derive it. Don't write what the framework gives you. -- **No backward-compatibility hacks** -- No renamed `_unused` vars, no re-exports for removed code, no `# removed` comments. If something is unused, delete it. Exception: production hotfixes with a follow-up cleanup ticket. -- **Clean Code** -- Readability over cleverness. Explicit imports only (no wildcards). Fail fast at boundaries. Meaningful names. Small focused functions. -- **Documentation as code** -- Every code change updates relevant docs in the same commit. Tool counts, event counts, file tables, component lists. This is the #1 recurring failure -- never skip it. +- **DDD + Clean Architecture** -- Respect bounded contexts. See CLAUDE.md for the 8 strict dependency rules. +- **No redundancy** -- DRY across layers. Three similar lines is better than a premature abstraction, but true duplication is never acceptable. +- **No dead code** -- No commented-out blocks, no unused imports, no orphaned functions. +- **No backward-compatibility hacks** -- If something is unused, delete it. +- **Clean Code** -- Readability over cleverness. Explicit imports only (no wildcards). Fail fast at boundaries. +- **Documentation as code** -- Every code change updates relevant docs in the same commit. Tool counts, event counts, file tables, component lists. - **Test before claiming complete** -- All quality gates must pass. Never say "done" without running the full suite. -## Architecture - -DDD monorepo with 10 bounded contexts. Each context follows `domain/` -> `application/` -> `infrastructure/` layering. - -### Layer Rules (Strict) - -1. `domain/` has ZERO external dependencies -- pure Python only -2. `application/` depends on `domain/` interfaces, never on `infrastructure/` -3. `infrastructure/` implements `domain/` repository interfaces -4. `tools.py` calls `application/` services, returns JSON for Claude -5. No cross-context domain imports -- communicate via `kernel/` primitives or `shared/` ports -6. `shared/` contains cross-cutting ports (ABCs) and value objects -- no infrastructure deps -7. RDKit imports ONLY in `chemistry/infrastructure/rdkit_adapter.py` - -### Multi-Model Orchestrator - -``` -Opus 4.6 (Director) -- Formulates hypotheses, evaluates evidence (NO tools) -Sonnet 4.5 (Researcher) -- Executes experiments with 70 tools (parallel: 2 per batch) -Haiku 4.5 (Summarizer) -- Compresses large outputs, classifies domains -``` - -### Bounded Contexts - -| Context | Domain | Purpose | -|---------|--------|---------| -| kernel | All | Shared primitives (SMILES, Molecule, exceptions) | -| shared | All | Cross-cutting ports and value objects (ChemistryPort, Fingerprint, Conformer3D) | -| literature | All | Paper search (Semantic Scholar) | -| chemistry | Molecular | RDKit cheminformatics | -| analysis | Molecular | Dataset exploration (ChEMBL, PubChem, GtoPdb) | -| prediction | Molecular | ML models (XGBoost, Chemprop) | -| simulation | Molecular | Docking, ADMET, targets (RCSB PDB, UniProt, Open Targets, EPA CompTox) | -| training | Training | Exercise physiology (evidence analysis, protocol comparison, injury risk, training metrics, clinical trials) | -| nutrition | Nutrition | Nutrition science (supplement evidence, labels, nutrients, safety, interactions, adequacy, inflammatory index) | -| investigation | All | Multi-model orchestration + domain registry + SQLite persistence | - -## Testing - -- Server: `uv run pytest` -- unit + integration tests -- Console: `bunx vitest run` -- component + utility tests -- Coverage minimum: 80% (server), enforced by pytest-cov - --- ## Adding a New Scientific Domain @@ -125,23 +47,23 @@ Before writing any code, research the domain deeply. This upfront work determine **What to research:** -1. **Problem definition** -- What scientific questions should this domain answer? What does "discovery" mean here? (e.g., molecular: find candidate drugs; training: find optimal training protocols) +1. **Problem definition** -- What scientific questions should this domain answer? What does "discovery" mean here? 2. **Data sources** -- Where does real experimental data come from? Identify free APIs, databases, or datasets. For each source: base URL, auth requirements, rate limits, response format, coverage -3. **Candidate criteria** -- What makes a good candidate in this domain? What scores matter? What thresholds separate good from bad? (e.g., molecular: prediction_score > 0.7, docking < -7.0 kcal/mol; training: effect_size > 0.5, evidence_grade A-B) +3. **Candidate criteria** -- What makes a good candidate in this domain? What scores matter? What thresholds separate good from bad? 4. **Hypothesis types** -- What kinds of hypotheses does this domain test? (mechanistic, structural, correlational, physiological, etc.) 5. **Visualization needs** -- How should results be displayed? Charts, 3D models, diagrams, maps, tables? 6. **Domain vocabulary** -- Key terms, units, standard notations that Claude needs in its prompts -**Document your research** in `research/{yourdomain}/`: +**Document your research** in `docs/research/{yourdomain}/`: ``` -research/yourdomain/ +docs/research/yourdomain/ data-sources.md # APIs, databases, auth, rate limits domain-model.md # Entities, scoring criteria, thresholds prior-work.md # Existing tools and approaches in this field ``` -See `research/molecular/`, `research/methodology/`, and the `training/` and `nutrition/` bounded contexts for examples of thorough domain research and implementation. +See `docs/research/molecular/`, `docs/research/methodology/`, and the `training/` and `nutrition/` bounded contexts for examples. ### Step 1: Create the Bounded Context @@ -221,7 +143,6 @@ YOUR_DOMAIN = DomainConfig( ), attribute_keys=("category", "source"), # Non-numeric candidate attributes negative_control_threshold=0.5, # Score below = correctly classified - # visualization is reactive: LiveLabViewer auto-appears for molecular tools, hypothesis_types=( "mechanistic", # Valid hypothesis_type values for this domain "correlational", @@ -229,7 +150,7 @@ YOUR_DOMAIN = DomainConfig( ), valid_domain_categories=( "your_domain", # Categories the classifier might output - "related_field", # Map these to your domain + "related_field", ), template_prompts=( { @@ -245,9 +166,9 @@ YOUR_DOMAIN = DomainConfig( ``` Key decisions: -- **Visualization**: Visualization is reactive -- LiveLabViewer auto-appears when molecular tool events (docking, descriptors, etc.) are detected in the SSE stream. Chart visualizations render inline from `VisualizationRendered` events. No configuration needed. +- **Visualization**: Visualization is reactive -- LiveLabViewer auto-appears when molecular tool events are detected in the SSE stream. Chart visualizations render inline from `VisualizationRendered` events. No configuration needed. - **`tool_tags`**: This frozenset determines which tools the Researcher can see. Include `"literature"` if you want paper search. -- **`director_examples`**: These are the most impactful part. Good multishot examples make the Director formulate domain-appropriate hypotheses. Use `{{` and `}}` for literal braces in f-string-like contexts. +- **`director_examples`**: These are the most impactful part. Good multishot examples make the Director formulate domain-appropriate hypotheses. - **`score_definitions`**: These become the columns in the CandidateTable. The frontend auto-generates color-coded cells based on thresholds. ### Step 4: Register Tools and Domain @@ -383,12 +304,9 @@ Update these files (in the same commit as the code): | File | What to update | |------|---------------| -| `CLAUDE.md` | Tool count, bounded contexts table, tools section, scopes list | +| `CLAUDE.md` | Bounded contexts table, key files | | `README.md` | Tool count, bounded contexts table, tools table, "What Can Ehrlich Investigate" | -| `docs/architecture.md` | Bounded contexts, tool count, data flow | -| `docs/roadmap.md` | Add completion entry | -| `web/src/lib/constants.ts` | STATS counts, DOMAINS array (toolCount, capabilities, sources, vizTools), DATA_SOURCES array | -| `console/` | Template prompts, domain badges, tool references where applicable | +| `web/src/lib/constants.ts` | STATS counts, DOMAINS array, DATA_SOURCES array | ### Step 9: Verify @@ -416,8 +334,8 @@ That's it. Your domain is live. The orchestrator will auto-detect it from the us 2. Register it in `_build_registry()` with the domain's tag 3. Write tests 4. Update the integration test tool count in `tests/integration/test_e2e.py` -5. Update docs (tool counts in CLAUDE.md, README.md, docs/architecture.md) -6. Update tool counts and references in `web/src/lib/constants.ts` (STATS, DOMAINS toolCount) and `console/` where applicable +5. Update docs (tool counts in CLAUDE.md, README.md) +6. Update tool counts and references in `web/src/lib/constants.ts` (STATS, DOMAINS toolCount) ### Improving Score Definitions diff --git a/README.md b/README.md index ab4195e..1534685 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,26 @@ # Ehrlich -AI-powered scientific discovery engine. Ehrlich uses Claude as a hypothesis-driven scientific reasoning engine that works across multiple scientific domains. The core engine is domain-agnostic: a pluggable `DomainConfig` system lets any domain bring its own tools, scoring, and visualization. +Open-source (AGPL-3.0), self-hostable scientific discovery engine. Ehrlich uses Claude as a hypothesis-driven scientific reasoning engine that works across multiple scientific domains. The core engine is domain-agnostic: a pluggable `DomainConfig` system lets any domain bring its own tools, scoring, and visualization. Run it on your own infrastructure with your own API keys, or use the [hosted instance](https://app.ehrlich.dev) where credits cover the Anthropic API costs. Named after Paul Ehrlich, the father of the "magic bullet" -- finding the right answer for any question. Built for the [Claude Code Hackathon](https://docs.anthropic.com/en/docs/claude-code-hackathon) (Feb 10-16, 2026). +## Philosophy + +Ehrlich is **COSS** (Commercial Open-Source Software) -- the same model used by Supabase, PostHog, Cal.com, and GitLab. The entire codebase is open source under AGPL-3.0. There is no proprietary version. + +**Two paths, same product:** + +| Path | How It Works | Cost | +|------|-------------|------| +| **Self-host** | Clone the repo, bring your own Anthropic API key | Free. No limits, no credits, no account needed | +| **Hosted instance** | Use app.ehrlich.dev | Credits cover Anthropic API costs (Opus is expensive) | + +Credits exist because Claude Opus costs real money per investigation. They make scientific reasoning **accessible** -- not monetized. A student in Mexico and a pharma company in Boston get the same 91 tools, the same 28 data sources, the same methodology. The model quality is the only variable. + +The AI is the scientist. The platform is the laboratory. + ## What Can Ehrlich Investigate? Ehrlich is **domain-agnostic**. The hypothesis-driven engine works for any scientific domain: @@ -25,9 +40,20 @@ Ehrlich is **domain-agnostic**. The hypothesis-driven engine works for any scien - **Supplement evidence** -- "What is the evidence for creatine on strength performance?" - **Nutrient profiling** -- "Compare protein content and amino acid profiles of whey vs plant-based supplements" +### Impact Evaluation +- **Social program analysis** -- "Evaluate the effectiveness of Sonora's sports scholarship program" +- **Causal inference** -- "Does the conditional cash transfer reduce school dropout rates?" (4 methods: DiD, PSM, RDD, Synthetic Control) +- **Cross-country benchmarking** -- "Compare cost-effectiveness of state sports programs in Mexico" +- Economic indicators from World Bank, WHO GHO, FRED, Census Bureau, BLS, INEGI, and Banxico +- US federal data: USAspending grants, College Scorecard education outcomes, HUD housing data, CDC WONDER mortality/natality, data.gov open datasets +- Mexico data: INEGI economic/demographic series, Banxico SIE financial series, datos.gob.mx open datasets +- CONEVAL/CREMAA indicator quality analysis (MIR framework validation) +- Cross-program comparison and international benchmarking +- Domain-agnostic causal inference tools (in analysis/ context) usable by any domain + ## Architecture -Ehrlich follows Domain-Driven Design with ten bounded contexts: +Ehrlich follows Domain-Driven Design with eleven bounded contexts: | Context | Purpose | |---------|---------| @@ -35,12 +61,13 @@ Ehrlich follows Domain-Driven Design with ten bounded contexts: | **shared** | Cross-cutting ports and value objects (ChemistryPort, Fingerprint, Conformer3D) | | **literature** | Scientific paper search (Semantic Scholar) and reference management | | **chemistry** | Cheminformatics: molecular descriptors, fingerprints, 3D conformers | -| **analysis** | Dataset exploration (ChEMBL, PubChem), substructure enrichment | +| **analysis** | Dataset exploration (ChEMBL, PubChem), substructure enrichment, domain-agnostic causal inference (DiD, PSM, RDD, Synthetic Control) | | **prediction** | ML modeling: train, predict, ensemble, cluster | | **simulation** | Molecular docking, ADMET, resistance, target discovery (RCSB PDB), toxicity (EPA CompTox) | | **training** | Exercise physiology: evidence analysis, protocol comparison, injury risk, training metrics, clinical trials | | **nutrition** | Nutrition science: supplement evidence, labels, nutrients, safety, interactions, adequacy, inflammatory index | | **investigation** | Multi-model agent orchestration with Director-Worker-Summarizer pattern + domain registry + MCP bridge | +| **impact** | Social program evaluation: economic indicators (World Bank, WHO GHO, FRED, Census, BLS, INEGI, Banxico), health (CDC WONDER), spending (USAspending), education (College Scorecard), housing (HUD), open data (data.gov, datos.gob.mx), CONEVAL/CREMAA MIR analysis. Causal estimators live in analysis/ (domain-agnostic) | ### Multi-Model Architecture @@ -48,7 +75,7 @@ Ehrlich uses a three-tier Claude model architecture for cost-efficient investiga ``` Opus 4.6 (Director) -- Formulates hypotheses, evaluates evidence, synthesizes (3-5 calls) -Sonnet 4.5 (Researcher) -- Executes experiments with 70 tools (10-20 calls) +Sonnet 4.5 (Researcher) -- Executes experiments with 91 tools (10-20 calls) Haiku 4.5 (Summarizer) -- Compresses large outputs, classifies domains (5-10 calls) ``` @@ -73,10 +100,23 @@ Cost: ~$3-4 per investigation (vs ~$11 with all-Opus). | [USDA FoodData](https://fdc.nal.usda.gov/) | Nutrient profiles for foods and supplements | 1.1M+ foods | | [OpenFDA CAERS](https://open.fda.gov/) | Supplement adverse event reports | 200K+ reports | | [RxNav](https://rxnav.nlm.nih.gov/) | Drug interaction screening (RxCUI resolution) | 100K+ drugs | +| [World Bank](https://data.worldbank.org/) | Development indicators by country (GDP, poverty, education) | 190+ countries | +| [WHO GHO](https://www.who.int/data/gho) | Global health statistics (life expectancy, mortality, disease) | 190+ countries | +| [FRED](https://fred.stlouisfed.org/) | US economic time series (GDP, unemployment, CPI) | 800K+ series | +| [Census Bureau](https://data.census.gov/) | US demographics, poverty, education (ACS 5-year) | 50 states + territories | +| [BLS](https://www.bls.gov/) | US labor statistics (unemployment, CPI, wages) | 130K+ series | +| [USAspending](https://www.usaspending.gov/) | Federal spending awards and grants | All federal agencies | +| [College Scorecard](https://collegescorecard.ed.gov/) | US higher education outcomes (completion, earnings) | 6K+ institutions | +| [HUD](https://www.huduser.gov/) | Fair Market Rents, income limits, housing data | All US counties | +| [CDC WONDER](https://wonder.cdc.gov/) | US mortality, natality, public health statistics | National-level | +| [data.gov](https://catalog.data.gov/) | US federal open dataset discovery (CKAN) | 300K+ datasets | +| [INEGI Indicadores](https://www.inegi.org.mx/app/api/indicadores/) | Mexico economic/demographic time series | 400K+ series | +| [Banxico SIE](https://www.banxico.org.mx/SieAPIRest/service/v1/) | Mexico central bank financial series | Exchange rates, inflation, reserves | +| [datos.gob.mx](https://datos.gob.mx/) | Mexico federal open dataset discovery (CKAN) | 1000+ datasets | All data sources are free and open-access. -## 70 Tools +## 91 Tools | Context | Tool | Description | |---------|------|-------------| @@ -95,6 +135,12 @@ All data sources are free and open-access. | Analysis | `analyze_substructures` | Chi-squared enrichment analysis | | Analysis | `compute_properties` | Property distributions (active vs inactive) | | Analysis | `search_pharmacology` | GtoPdb curated receptor/ligand interactions | +| Causal | `estimate_did` | Difference-in-differences causal estimation | +| Causal | `estimate_psm` | Propensity score matching with balance diagnostics | +| Causal | `estimate_rdd` | Regression discontinuity design (sharp/fuzzy) | +| Causal | `estimate_synthetic_control` | Synthetic control method | +| Causal | `assess_threats` | Validity threat assessment for causal methods | +| Causal | `compute_cost_effectiveness` | Cost per unit outcome, ICER | | Prediction | `train_model` | Train XGBoost on SMILES+activity data | | Prediction | `predict_candidates` | Score compounds with trained model | | Prediction | `cluster_compounds` | Butina structural clustering | @@ -108,7 +154,7 @@ All data sources are free and open-access. | Simulation | `assess_resistance` | Resistance mutation scoring | | Simulation | `get_protein_annotation` | UniProt protein function and disease links | | Simulation | `search_disease_targets` | Open Targets disease-target associations | -| Training | `search_training_literature` | Training science literature via Semantic Scholar | +| Training | `search_training_literature` | Training science literature with MeSH expansion, study type ranking, non-human filtering | | Training | `analyze_training_evidence` | Pooled effect sizes, heterogeneity, evidence grading | | Training | `compare_protocols` | Evidence-weighted protocol comparison | | Training | `assess_injury_risk` | Knowledge-based injury risk scoring | @@ -119,16 +165,25 @@ All data sources are free and open-access. | Training | `compute_performance_model` | Banister fitness-fatigue model (CTL/ATL/TSB) | | Training | `compute_dose_response` | Dose-response curve from dose-effect data | | Training | `plan_periodization` | Evidence-based periodization planning (linear/undulating/block) | -| Nutrition | `search_supplement_evidence` | Supplement efficacy literature search | +| Nutrition | `search_supplement_evidence` | Supplement evidence with GRADE-style ranking, retracted paper exclusion | | Nutrition | `search_supplement_labels` | NIH DSLD supplement product ingredient lookup | | Nutrition | `search_nutrient_data` | USDA FoodData Central nutrient profiles | | Nutrition | `search_supplement_safety` | OpenFDA CAERS adverse event reports | -| Nutrition | `compare_nutrients` | Side-by-side nutrient comparison between foods/supplements | +| Nutrition | `compare_nutrients` | Side-by-side nutrient comparison with per-nutrient deltas, winner, MAR score | | Nutrition | `assess_nutrient_adequacy` | DRI-based nutrient adequacy assessment | | Nutrition | `check_intake_safety` | Tolerable Upper Intake Level safety screening | | Nutrition | `check_interactions` | Drug-supplement interaction screening via RxNav | | Nutrition | `analyze_nutrient_ratios` | Key nutrient ratio analysis (omega-6:3, Ca:Mg, etc.) | | Nutrition | `compute_inflammatory_index` | Simplified Dietary Inflammatory Index scoring | +| Impact | `search_economic_indicators` | Query economic time series from FRED, BLS, Census, World Bank, WHO GHO, INEGI, or Banxico | +| Impact | `search_health_indicators` | Search WHO GHO or CDC WONDER for health indicators | +| Impact | `fetch_benchmark` | Get comparison values from international or US data sources | +| Impact | `compare_programs` | Cross-program comparison using statistical tests | +| Impact | `search_spending_data` | Search USAspending.gov for federal spending awards and grants | +| Impact | `search_education_data` | Search College Scorecard for US higher education outcomes | +| Impact | `search_housing_data` | Search HUD for Fair Market Rents and income limits | +| Impact | `search_open_data` | Search data.gov or datos.gob.mx CKAN catalog for open datasets | +| Impact | `analyze_program_indicators` | Validate MIR indicators against CREMAA quality criteria (CONEVAL methodology) | | Visualization | `render_binding_scatter` | Scatter plot of compound binding affinities | | Visualization | `render_admet_radar` | Radar chart of ADMET/drug-likeness properties | | Visualization | `render_training_timeline` | Training load timeline with ACWR danger zones | @@ -141,6 +196,11 @@ All data sources are free and open-access. | Visualization | `render_nutrient_comparison` | Grouped bar chart comparing nutrient profiles | | Visualization | `render_nutrient_adequacy` | Horizontal bar chart with DRI adequacy + MAR score | | Visualization | `render_therapeutic_window` | Therapeutic window chart (EAR/RDA/AI/UL zones) | +| Visualization | `render_program_dashboard` | Multi-indicator KPI dashboard with target tracking | +| Visualization | `render_geographic_comparison` | Region bar chart with benchmark reference line | +| Visualization | `render_parallel_trends` | DiD parallel trends chart (treatment vs control) | +| Visualization | `render_rdd_plot` | Regression discontinuity scatter with cutoff line | +| Visualization | `render_causal_diagram` | DAG showing treatment, outcome, confounders | | Statistics | `run_statistical_test` | Compare two numeric groups (auto-selects t-test/Welch/Mann-Whitney) | | Statistics | `run_categorical_test` | Test contingency tables (auto-selects Fisher's exact/chi-squared) | | Investigation | `propose_hypothesis` | Register testable hypothesis | @@ -149,6 +209,7 @@ All data sources are free and open-access. | Investigation | `record_finding` | Record finding linked to hypothesis | | Investigation | `record_negative_control` | Validate model with known-inactive compounds | | Investigation | `search_prior_research` | Search past investigation findings via full-text search | +| Investigation | `query_uploaded_data` | Query user-uploaded files (CSV/Excel filtering, PDF text) | | Investigation | `conclude_investigation` | Final summary with ranked candidates | ## Tech Stack @@ -162,6 +223,8 @@ All data sources are free and open-access. ## Quick Start +> **Self-hosting?** You use your own Anthropic API key directly -- no credits, no account, no hosted dependency. The credit system only applies to the managed hosted instance. + ### Prerequisites - [uv](https://docs.astral.sh/uv/) (Python package manager) @@ -188,6 +251,9 @@ All data sources are free and open-access. | `EHRLICH_MAX_ITERATIONS_PER_PHASE` | No | Max iterations per experiment in multi-model mode (default: 10) | | `EHRLICH_LOG_LEVEL` | No | Logging level (default: INFO) | | `EHRLICH_COMPTOX_API_KEY` | No | EPA CompTox API key (free, for toxicity data) | +| `INEGI_API_TOKEN` | No | INEGI Indicadores API token (Mexico economic/demographic data) | +| `BANXICO_API_TOKEN` | No | Banxico SIE API token (Mexico central bank financial series) | +| `DATOSGOB_API_TOKEN` | No | datos.gob.mx API token (Mexico open datasets, optional) | ### Database @@ -259,10 +325,9 @@ Server at :8000, Console at :3000. | GET | `/api/v1/health` | Health check | | GET | `/api/v1/methodology` | Methodology: phases, domains, tools, data sources, models | | GET | `/api/v1/stats` | Aggregate counts (tools, domains, phases, data sources, events) | -| GET | `/api/v1/investigate/{id}` | Full investigation detail | -| GET | `/api/v1/molecule/depict?smiles=&w=&h=` | 2D SVG depiction (`image/svg+xml`, cached 24h) | -| GET | `/api/v1/molecule/conformer?smiles=` | 3D conformer (JSON: mol_block, energy, num_atoms) | -| GET | `/api/v1/molecule/descriptors?smiles=` | Molecular descriptors + Lipinski pass/fail | +| GET | `/api/v1/molecule/depict?smiles=&w=&h=` | 2D SVG depiction (`image/svg+xml`, cached 24h). SMILES max 500 chars | +| GET | `/api/v1/molecule/conformer?smiles=` | 3D conformer (JSON: mol_block, energy, num_atoms). SMILES max 500 chars | +| GET | `/api/v1/molecule/descriptors?smiles=` | Molecular descriptors + Lipinski pass/fail. SMILES max 500 chars | | GET | `/api/v1/targets` | List protein targets (pdb_id, name, organism) | ### Protected (WorkOS JWT required) @@ -271,8 +336,11 @@ Server at :8000, Console at :3000. |--------|------|-------------| | GET | `/api/v1/investigate` | List user's investigations (most recent first) | | POST | `/api/v1/investigate` | Create new investigation (`director_tier`: haiku/sonnet/opus) | -| GET | `/api/v1/investigate/{id}/stream` | SSE stream of investigation events (supports `?token=` for EventSource) | -| POST | `/api/v1/investigate/{id}/approve` | Approve/reject formulated hypotheses | +| GET | `/api/v1/investigate/{id}` | Full investigation detail (owner only) | +| GET | `/api/v1/investigate/{id}/stream` | SSE stream of investigation events (owner only, supports `?token=`) | +| GET | `/api/v1/investigate/{id}/paper` | Structured scientific paper + visualizations from completed investigation (owner only). PDF via `/paper/:id` route + browser print | +| POST | `/api/v1/investigate/{id}/approve` | Approve/reject formulated hypotheses (owner only) | +| POST | `/api/v1/upload` | Upload file (CSV/XLSX/PDF) for investigation data, returns preview | | GET | `/api/v1/credits/balance` | Current credit balance + BYOK status | ### SSE Event Types diff --git a/console/bun.lock b/console/bun.lock index 0432057..a769924 100644 --- a/console/bun.lock +++ b/console/bun.lock @@ -6,6 +6,11 @@ "name": "ehrlich-console", "dependencies": { "3dmol": "^2.5.4", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.64.0", "@tanstack/react-router": "^1.114.0", "@visx/axis": "^3.12.0", @@ -18,13 +23,16 @@ "@visx/tooltip": "^3.12.0", "@workos-inc/authkit-react": "^0.4.0", "@xyflow/react": "^12.10.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "lucide-react": "^0.474.0", "motion": "^12.34.0", + "radix-ui": "^1.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-markdown": "^9.0.0", + "react-markdown": "^10.1.0", "recharts": "^3", + "rehype-sanitize": "^6.0.0", "sonner": "^1.7.0", "tailwind-merge": "^3.0.0", "zustand": "^5.0.0", @@ -158,6 +166,14 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -168,6 +184,126 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -418,6 +554,8 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -458,6 +596,8 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], @@ -530,6 +670,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -572,12 +714,16 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], @@ -784,18 +930,26 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -814,6 +968,8 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], @@ -928,6 +1084,10 @@ "upng-js": ["upng-js@2.1.0", "", { "dependencies": { "pako": "^1.0.5" } }, "sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -972,6 +1132,26 @@ "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -1014,6 +1194,10 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/console/index.html b/console/index.html index 5aa971f..1063ef3 100644 --- a/console/index.html +++ b/console/index.html @@ -1,15 +1,21 @@ - - - - Ehrlich - Antimicrobial Discovery - - - - - -
- - - + + + + + Ehrlich Console + + + + + + + +
+ + + + \ No newline at end of file diff --git a/console/package.json b/console/package.json index 5c4fc4c..d6fb46f 100644 --- a/console/package.json +++ b/console/package.json @@ -13,8 +13,12 @@ }, "dependencies": { "3dmol": "^2.5.4", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.64.0", - "@workos-inc/authkit-react": "^0.4.0", "@tanstack/react-router": "^1.114.0", "@visx/axis": "^3.12.0", "@visx/curve": "^3.12.0", @@ -24,14 +28,18 @@ "@visx/scale": "^3.12.0", "@visx/shape": "^3.12.0", "@visx/tooltip": "^3.12.0", + "@workos-inc/authkit-react": "^0.4.0", "@xyflow/react": "^12.10.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "lucide-react": "^0.474.0", "motion": "^12.34.0", + "radix-ui": "^1.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-markdown": "^9.0.0", + "react-markdown": "^10.1.0", "recharts": "^3", + "rehype-sanitize": "^6.0.0", "sonner": "^1.7.0", "tailwind-merge": "^3.0.0", "zustand": "^5.0.0" diff --git a/console/public/logo.svg b/console/public/logo.svg new file mode 100644 index 0000000..878c889 --- /dev/null +++ b/console/public/logo.svg @@ -0,0 +1,3204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/console/src/assets/images/logo.svg b/console/src/assets/images/logo.svg new file mode 100644 index 0000000..878c889 --- /dev/null +++ b/console/src/assets/images/logo.svg @@ -0,0 +1,3204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/console/src/features/investigation/components/BYOKSettings.test.tsx b/console/src/features/investigation/components/BYOKSettings.test.tsx index 39f1bcc..786114e 100644 --- a/console/src/features/investigation/components/BYOKSettings.test.tsx +++ b/console/src/features/investigation/components/BYOKSettings.test.tsx @@ -16,7 +16,7 @@ function renderWithProvider() { describe("BYOKSettings", () => { beforeEach(() => { - localStorage.clear(); + sessionStorage.clear(); }); afterEach(() => { @@ -28,7 +28,7 @@ describe("BYOKSettings", () => { expect(screen.getByPlaceholderText("sk-ant-...")).toBeInTheDocument(); }); - it("saves key to localStorage", () => { + it("saves key to sessionStorage", () => { renderWithProvider(); const input = screen.getByPlaceholderText("sk-ant-..."); fireEvent.change(input, { target: { value: "sk-ant-test123" } }); @@ -36,23 +36,23 @@ describe("BYOKSettings", () => { const saveButton = saveButtons.find((b) => b.textContent === "Save"); expect(saveButton).toBeDefined(); fireEvent.click(saveButton!); - expect(localStorage.getItem("ehrlich_api_key")).toBe("sk-ant-test123"); + expect(sessionStorage.getItem("ehrlich_api_key")).toBe("sk-ant-test123"); }); it("shows stored key indicator", () => { - localStorage.setItem("ehrlich_api_key", "sk-ant-mykey123"); + sessionStorage.setItem("ehrlich_api_key", "sk-ant-mykey123"); renderWithProvider(); expect(screen.getByText(/Using your own key/)).toBeInTheDocument(); }); it("clears key on remove click", () => { - localStorage.setItem("ehrlich_api_key", "sk-ant-remove"); + sessionStorage.setItem("ehrlich_api_key", "sk-ant-remove"); renderWithProvider(); const removeButtons = screen.getAllByRole("button"); const removeButton = removeButtons.find((b) => b.textContent?.includes("Remove")); expect(removeButton).toBeDefined(); fireEvent.click(removeButton!); - expect(localStorage.getItem("ehrlich_api_key")).toBeNull(); + expect(sessionStorage.getItem("ehrlich_api_key")).toBeNull(); }); it("renders BYOK heading", () => { diff --git a/console/src/features/investigation/components/BYOKSettings.tsx b/console/src/features/investigation/components/BYOKSettings.tsx index c557adf..bae0ad4 100644 --- a/console/src/features/investigation/components/BYOKSettings.tsx +++ b/console/src/features/investigation/components/BYOKSettings.tsx @@ -7,18 +7,18 @@ const STORAGE_KEY = "ehrlich_api_key"; export function BYOKSettings() { const queryClient = useQueryClient(); const [value, setValue] = useState(""); - const stored = localStorage.getItem(STORAGE_KEY); + const stored = sessionStorage.getItem(STORAGE_KEY); function handleSave() { const trimmed = value.trim(); if (!trimmed) return; - localStorage.setItem(STORAGE_KEY, trimmed); + sessionStorage.setItem(STORAGE_KEY, trimmed); setValue(""); void queryClient.invalidateQueries({ queryKey: ["credits"] }); } function handleClear() { - localStorage.removeItem(STORAGE_KEY); + sessionStorage.removeItem(STORAGE_KEY); void queryClient.invalidateQueries({ queryKey: ["credits"] }); } @@ -66,7 +66,7 @@ export function BYOKSettings() {

{stored ? "Investigations use your key directly. No platform credits consumed." - : "Provide your Anthropic API key to bypass credit limits. Key is stored locally."} + : "Provide your Anthropic API key to bypass credit limits. Key is stored in this browser tab only and cleared when you close it."}

); diff --git a/console/src/features/investigation/components/CandidateComparison.tsx b/console/src/features/investigation/components/CandidateComparison.tsx index 22a65d3..c481c23 100644 --- a/console/src/features/investigation/components/CandidateComparison.tsx +++ b/console/src/features/investigation/components/CandidateComparison.tsx @@ -1,6 +1,6 @@ import { X } from "lucide-react"; import { cn } from "@/shared/lib/utils"; -import { MolViewer2D } from "@/features/molecule/components/MolViewer2D"; +import { MolViewer2D } from "@/features/molecule"; import type { CandidateRow, DomainDisplayConfig } from "../types"; interface CandidateComparisonProps { diff --git a/console/src/features/investigation/components/CandidateDetail.tsx b/console/src/features/investigation/components/CandidateDetail.tsx index 15d3900..97b365b 100644 --- a/console/src/features/investigation/components/CandidateDetail.tsx +++ b/console/src/features/investigation/components/CandidateDetail.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from "react"; -import { MolViewer2D } from "@/features/molecule/components/MolViewer2D"; -import { MolViewer3D } from "@/features/molecule/components/MolViewer3D"; +import { MolViewer2D, MolViewer3D } from "@/features/molecule"; import { apiFetch } from "@/shared/lib/api"; interface ConformerData { @@ -93,11 +92,10 @@ function MolecularDetail({ smiles, name }: { smiles: string; name: string }) {
Properties {descriptors.passes_lipinski ? "Lipinski OK" : "Lipinski Fail"} diff --git a/console/src/features/investigation/components/CandidateTable.tsx b/console/src/features/investigation/components/CandidateTable.tsx index 74f9143..0cc13c7 100644 --- a/console/src/features/investigation/components/CandidateTable.tsx +++ b/console/src/features/investigation/components/CandidateTable.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { ChevronDown, ChevronRight, GitCompareArrows, Square, CheckSquare } from "lucide-react"; import { cn } from "@/shared/lib/utils"; -import { MolViewer2D } from "@/features/molecule/components/MolViewer2D"; +import { MolViewer2D } from "@/features/molecule"; import type { CandidateRow, DomainDisplayConfig } from "../types"; import { CandidateDetail } from "./CandidateDetail"; import { CandidateComparison } from "./CandidateComparison"; diff --git a/console/src/features/investigation/components/ClassificationCard.tsx b/console/src/features/investigation/components/ClassificationCard.tsx new file mode 100644 index 0000000..293b725 --- /dev/null +++ b/console/src/features/investigation/components/ClassificationCard.tsx @@ -0,0 +1,30 @@ +import { Badge } from "@/shared/components/ui/badge"; +import type { DomainDisplayConfig } from "../types"; + +interface ClassificationCardProps { + domainConfig: DomainDisplayConfig | null; +} + +export function ClassificationCard({ domainConfig }: ClassificationCardProps) { + if (!domainConfig) { + return ( +
+
+ Classifying research domain... +
+ ); + } + + return ( +
+ + {domainConfig.display_name} + + {domainConfig.domains && domainConfig.domains.length > 1 && ( + + Multi-domain: {domainConfig.domains.map((d) => d.display_name).join(" + ")} + + )} +
+ ); +} diff --git a/console/src/features/investigation/components/CompletionSummaryCard.tsx b/console/src/features/investigation/components/CompletionSummaryCard.tsx index 3d310f7..07da228 100644 --- a/console/src/features/investigation/components/CompletionSummaryCard.tsx +++ b/console/src/features/investigation/components/CompletionSummaryCard.tsx @@ -56,28 +56,21 @@ export function CompletionSummaryCard({
- {expanded && cost?.byModel && ( + {expanded && cost?.byRole && (
- Model + Role Input Output Cost - {Object.entries(cost.byModel).map(([model, data]) => { - const shortName = model.includes("opus") - ? "Director (Opus)" - : model.includes("sonnet") - ? "Researcher (Sonnet)" - : "Summarizer (Haiku)"; - return ( -
- {shortName} - {data.input_tokens.toLocaleString()} - {data.output_tokens.toLocaleString()} - ${data.cost_usd.toFixed(4)} -
- ); - })} + {Object.entries(cost.byRole).map(([role, data]) => ( +
+ {role} ({data.model_display}) + {data.input_tokens.toLocaleString()} + {data.output_tokens.toLocaleString()} + ${data.cost_usd.toFixed(4)} +
+ ))}
)} diff --git a/console/src/features/investigation/components/CostBadge.tsx b/console/src/features/investigation/components/CostBadge.tsx index fc062e0..afa61f5 100644 --- a/console/src/features/investigation/components/CostBadge.tsx +++ b/console/src/features/investigation/components/CostBadge.tsx @@ -5,16 +5,12 @@ interface CostBadgeProps { cost: CostInfo | null; } -const MODEL_LABELS: Record = { - "claude-opus-4-6": "Director", - "claude-sonnet-4-5-20250929": "Researcher", - "claude-haiku-4-5-20251001": "Summarizer", +const ROLE_LABELS: Record = { + director: "Director", + researcher: "Researcher", + summarizer: "Summarizer", }; -function modelLabel(model: string): string { - return MODEL_LABELS[model] ?? model.split("-").slice(1, 3).join("-"); -} - export function CostBadge({ cost }: CostBadgeProps) { const [showBreakdown, setShowBreakdown] = useState(false); @@ -24,7 +20,7 @@ export function CostBadge({ cost }: CostBadgeProps) {
- {showBreakdown && cost.byModel && ( + {showBreakdown && cost.byRole && (
- {Object.entries(cost.byModel).map(([model, data]) => ( + {Object.entries(cost.byRole).map(([role, data]) => (
- {modelLabel(model)} + {ROLE_LABELS[role] ?? role} + + ({data.model_display}) +
diff --git a/console/src/features/investigation/components/DataPreview.test.tsx b/console/src/features/investigation/components/DataPreview.test.tsx new file mode 100644 index 0000000..1088956 --- /dev/null +++ b/console/src/features/investigation/components/DataPreview.test.tsx @@ -0,0 +1,167 @@ +import { render, fireEvent, cleanup } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { DataPreview } from "./DataPreview"; +import type { TabularPreview, DocumentPreview } from "../types"; + +const tabularPreview: TabularPreview = { + columns: ["name", "age", "score"], + dtypes: ["str", "int", "float"], + row_count: 150, + sample_rows: [ + ["Alice", "30", "95.5"], + ["Bob", "25", "88.0"], + ], +}; + +const documentPreview: DocumentPreview = { + text: "This is a sample PDF document with some content for testing purposes.", + page_count: 3, +}; + +describe("DataPreview", () => { + afterEach(() => { + cleanup(); + }); + + it("renders filename", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("data.csv"); + }); + + it("renders tabular preview with column names", () => { + const { container } = render( + , + ); + const text = container.textContent ?? ""; + expect(text).toContain("name"); + expect(text).toContain("age"); + expect(text).toContain("score"); + }); + + it("renders tabular preview with sample rows", () => { + const { container } = render( + , + ); + const text = container.textContent ?? ""; + expect(text).toContain("Alice"); + expect(text).toContain("Bob"); + expect(text).toContain("95.5"); + }); + + it("renders tabular row count badge", () => { + const { container } = render( + , + ); + const text = container.textContent ?? ""; + expect(text).toContain("150 rows"); + expect(text).toContain("3 columns"); + }); + + it("renders document preview with text excerpt", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("sample PDF document"); + }); + + it("renders document page count badge", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("3 pages"); + }); + + it("renders singular page label for single page", () => { + const singlePage: DocumentPreview = { text: "Content", page_count: 1 }; + const { container } = render( + , + ); + expect(container.textContent).toContain("1 page"); + expect(container.textContent).not.toContain("1 pages"); + }); + + it("calls onRemove when remove button is clicked", () => { + const onRemove = vi.fn(); + const { container } = render( + , + ); + const removeBtn = container.querySelector("button")!; + fireEvent.click(removeBtn); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + it("truncates long document text at 300 chars", () => { + const longText: DocumentPreview = { + text: "A".repeat(400), + page_count: 5, + }; + const { container } = render( + , + ); + const text = container.textContent ?? ""; + expect(text).toContain("..."); + expect(text).not.toContain("A".repeat(400)); + }); + + it("renders dtypes next to column names", () => { + const { container } = render( + , + ); + const text = container.textContent ?? ""; + expect(text).toContain("str"); + expect(text).toContain("int"); + expect(text).toContain("float"); + }); +}); diff --git a/console/src/features/investigation/components/DataPreview.tsx b/console/src/features/investigation/components/DataPreview.tsx new file mode 100644 index 0000000..618d5e9 --- /dev/null +++ b/console/src/features/investigation/components/DataPreview.tsx @@ -0,0 +1,97 @@ +import { X } from "lucide-react"; +import type { TabularPreview, DocumentPreview } from "../types"; + +interface DataPreviewProps { + filename: string; + contentType: string; + preview: TabularPreview | DocumentPreview; + onRemove: () => void; +} + +function isTabular(preview: TabularPreview | DocumentPreview): preview is TabularPreview { + return "columns" in preview; +} + +export function DataPreview({ filename, preview, onRemove }: DataPreviewProps) { + return ( +
+ + +

+ {filename} +

+ + {isTabular(preview) ? ( +
+
+ {preview.columns.map((col, i) => ( + + {col} + {preview.dtypes[i]} + + ))} +
+
+ + + + {preview.columns.map((col) => ( + + ))} + + + + {preview.sample_rows.slice(0, 5).map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {col} +
+ {cell} +
+
+
+ + {preview.row_count.toLocaleString()} rows + + + {preview.columns.length} columns + +
+
+ ) : ( +
+

+ {preview.text.length > 300 + ? preview.text.slice(0, 300) + "..." + : preview.text} +

+ + {preview.page_count} {preview.page_count === 1 ? "page" : "pages"} + +
+ )} +
+ ); +} diff --git a/console/src/features/investigation/components/DiagramRenderer.tsx b/console/src/features/investigation/components/DiagramRenderer.tsx index b47dd09..05f2a02 100644 --- a/console/src/features/investigation/components/DiagramRenderer.tsx +++ b/console/src/features/investigation/components/DiagramRenderer.tsx @@ -1,59 +1,13 @@ -import { ReactFlow, Background, Controls, MiniMap, Handle, Position } from "@xyflow/react"; -import type { NodeProps, Node, Edge } from "@xyflow/react"; +import { ReactFlow, Background, Controls, MiniMap, Panel } from "@xyflow/react"; +import type { Node, Edge } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; +import { BrainCircuit, FlaskConical, FileText, Wrench } from "lucide-react"; import type { InvestigationNodeData } from "../lib/diagram-builder"; +import { InvestigationNode } from "./InvestigationNode"; -// -- Custom node types (defined outside component to avoid re-registration) -- +// -- Custom node types ---------------------------------------------------- -function InvestigationNode({ data }: NodeProps>) { - return ( -
- - - {data.label} - - - {data.sublabel} - - -
- ); -} - -function AnnotationNode({ data }: NodeProps>) { +function AnnotationNode({ data }: { data: InvestigationNodeData }) { return ( +

Legend

+
+ {LEGEND_ITEMS.map((item) => ( +
+ + {item.label} +
+ ))} +
+
+ Hover over nodes to see detailed scientific reasoning. +
+
+ ); +} + // -- Diagram renderer component ------------------------------------------- interface DiagramRendererProps { @@ -95,17 +77,25 @@ export function DiagramRenderer({ nodes, edges }: DiagramRendererProps) { nodesConnectable={false} elementsSelectable={false} proOptions={{ hideAttribution: true }} - colorMode="dark" - minZoom={0.3} + colorMode="dark" // Enforce dark mode or use system preference? Debug used forced styles mostly. + minZoom={0.1} maxZoom={2} + className="bg-zinc-50 dark:bg-zinc-900" > - - + + (n.data as InvestigationNodeData).fill ?? "#1f2937"} maskColor="rgba(15, 18, 25, 0.8)" style={{ backgroundColor: "#0f1219" }} /> + + + ); } diff --git a/console/src/features/investigation/components/FileUpload.test.tsx b/console/src/features/investigation/components/FileUpload.test.tsx new file mode 100644 index 0000000..ec2a830 --- /dev/null +++ b/console/src/features/investigation/components/FileUpload.test.tsx @@ -0,0 +1,125 @@ +import { render, fireEvent, cleanup } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { UploadResponse } from "../types"; + +const mockMutateAsync = vi.fn(); +const mockUploadState = { mutateAsync: mockMutateAsync, isError: false }; + +vi.mock("../hooks/use-upload", () => ({ + useUpload: () => mockUploadState, +})); + +import { FileUpload } from "./FileUpload"; + +function renderWithProvider( + props: { onFilesChanged: (files: { file_id: string; filename: string }[]) => void }, +) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +describe("FileUpload", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockUploadState.isError = false; + }); + + it("renders drop zone with instruction text", () => { + const { container } = renderWithProvider({ onFilesChanged: vi.fn() }); + const text = container.textContent ?? ""; + expect(text).toContain("Drop CSV, Excel, or PDF"); + }); + + it("renders hidden file input with accepted types", () => { + const { container } = renderWithProvider({ onFilesChanged: vi.fn() }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.accept).toBe(".csv,.xlsx,.pdf"); + expect(input.multiple).toBe(true); + }); + + it("uploads file and calls onFilesChanged", async () => { + const uploadResult: UploadResponse = { + file_id: "abc-123", + filename: "test.csv", + content_type: "text/csv", + preview: { + columns: ["col1", "col2"], + dtypes: ["str", "int"], + row_count: 10, + sample_rows: [["a", "1"]], + }, + }; + mockMutateAsync.mockResolvedValueOnce(uploadResult); + + const onFilesChanged = vi.fn(); + const { container } = renderWithProvider({ onFilesChanged }); + + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(["data"], "test.csv", { type: "text/csv" }); + fireEvent.change(input, { target: { files: [file] } }); + + // Wait for async upload to complete + await vi.waitFor(() => { + expect(onFilesChanged).toHaveBeenCalledWith([ + expect.objectContaining({ file_id: "abc-123", filename: "test.csv" }), + ]); + }); + }); + + it("shows per-file error when upload fails", async () => { + mockMutateAsync.mockRejectedValueOnce({ message: "Unsupported file type" }); + + const { container } = renderWithProvider({ onFilesChanged: vi.fn() }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(["data"], "bad.json", { type: "application/json" }); + fireEvent.change(input, { target: { files: [file] } }); + + await vi.waitFor(() => { + expect(container.textContent).toContain("bad.json"); + expect(container.textContent).toContain("Unsupported file type"); + }); + }); + + it("rejects oversized file without calling upload", () => { + const onFilesChanged = vi.fn(); + const { container } = renderWithProvider({ onFilesChanged }); + + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + // Create a file that reports > 50 MB + const bigFile = new File(["x"], "huge.csv", { type: "text/csv" }); + Object.defineProperty(bigFile, "size", { value: 51 * 1024 * 1024 }); + + fireEvent.change(input, { target: { files: [bigFile] } }); + + expect(container.textContent).toContain("huge.csv"); + expect(container.textContent).toContain("File too large"); + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); + + it("shows file count after upload", async () => { + const uploadResult: UploadResponse = { + file_id: "f-1", + filename: "a.csv", + content_type: "text/csv", + preview: { columns: ["x"], dtypes: ["int"], row_count: 1, sample_rows: [["1"]] }, + }; + mockMutateAsync.mockResolvedValueOnce(uploadResult); + + const { container } = renderWithProvider({ onFilesChanged: vi.fn() }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(["d"], "a.csv")] } }); + + await vi.waitFor(() => { + expect(container.textContent).toContain("1 / 10 files"); + }); + }); +}); diff --git a/console/src/features/investigation/components/FileUpload.tsx b/console/src/features/investigation/components/FileUpload.tsx new file mode 100644 index 0000000..ad70e73 --- /dev/null +++ b/console/src/features/investigation/components/FileUpload.tsx @@ -0,0 +1,236 @@ +import { + useCallback, + useRef, + useState, + forwardRef, + useImperativeHandle, +} from "react"; +import { Upload, Loader2, AlertCircle } from "lucide-react"; +import { useUpload } from "../hooks/use-upload"; +import { DataPreview } from "./DataPreview"; +import type { UploadResponse } from "../types"; + +const ACCEPTED_TYPES = ".csv,.xlsx,.pdf"; +const MAX_FILES = 10; +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + +export interface UploadedFile extends UploadResponse { + // UploadResponse already has file_id, filename, content_type, preview +} + +export interface FileUploadRef { + open: () => void; + addFiles: (files: File[]) => void; +} + +interface FileUploadProps { + onFilesChanged: (files: UploadedFile[]) => void; + trigger?: React.ReactNode; + hideList?: boolean; +} + +function formatSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} B`; +} + +export const FileUpload = forwardRef( + ({ onFilesChanged, trigger, hideList = false }, ref) => { + const [files, setFiles] = useState([]); + const [dragOver, setDragOver] = useState(false); + const [pendingCount, setPendingCount] = useState(0); + const [errors, setErrors] = useState<{ name: string; message: string }[]>([]); + const inputRef = useRef(null); + const upload = useUpload(); + + const updateParent = useCallback( + (updated: UploadedFile[]) => { + onFilesChanged(updated); + }, + [onFilesChanged], + ); + + useImperativeHandle(ref, () => ({ + open: () => inputRef.current?.click(), + addFiles: (offsetFiles: File[]) => handleFiles(offsetFiles), + })); + + function addError(name: string, message: string) { + setErrors((prev) => [...prev, { name, message }]); + } + + function dismissError(index: number) { + setErrors((prev) => prev.filter((_, i) => i !== index)); + } + + function handleRemove(fileId: string) { + setFiles((prev) => { + const updated = prev.filter((f) => f.file_id !== fileId); + updateParent(updated); + return updated; + }); + } + + async function processFile(file: File) { + if (file.size > MAX_FILE_SIZE) { + addError( + file.name, + `File too large (${formatSize(file.size)}). Maximum is ${formatSize(MAX_FILE_SIZE)}.`, + ); + return; + } + + setPendingCount((c) => c + 1); + try { + const result = await upload.mutateAsync(file); + setFiles((prev) => { + const updated = [...prev, result]; + updateParent(updated); + return updated; + }); + } catch (err: unknown) { + const detail = + err && typeof err === "object" && "message" in err + ? String((err as { message: string }).message) + : "Upload failed"; + addError(file.name, detail); + } finally { + setPendingCount((c) => c - 1); + } + } + + function handleFiles(fileList: FileList | File[]) { + const incoming = Array.from(fileList); + const remaining = MAX_FILES - files.length; + if (incoming.length > remaining) { + addError( + `${incoming.length} files`, + `Only ${remaining} more file${remaining === 1 ? "" : "s"} allowed (max ${MAX_FILES}).`, + ); + } + const batch = incoming.slice(0, remaining); + for (const file of batch) { + void processFile(file); + } + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer.files.length > 0) { + handleFiles(e.dataTransfer.files); + } + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + setDragOver(true); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + setDragOver(false); + } + + function handleInputChange(e: React.ChangeEvent) { + if (e.target.files && e.target.files.length > 0) { + handleFiles(e.target.files); + e.target.value = ""; + } + } + + const atCapacity = files.length >= MAX_FILES; + + return ( +
+ {!atCapacity && ( +
inputRef.current?.click()} + onDrop={handleDrop} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + className={trigger ? "inline-block cursor-pointer" : ""} + > + {trigger ? ( + trigger + ) : ( + + )} +
+ )} + + + + {!hideList && files.length > 0 && ( +

+ {files.length} / {MAX_FILES} files +

+ )} + + {/* Errors always shown locally for now */} + {errors.length > 0 && ( +
+ {errors.map((err, i) => ( +
+ +

+ {err.name}: {err.message} +

+ +
+ ))} +
+ )} + + {!hideList && files.length > 0 && ( +
+ {files.map((f) => ( + handleRemove(f.file_id)} + /> + ))} +
+ )} +
+ ); + }, +); +FileUpload.displayName = "FileUpload"; diff --git a/console/src/features/investigation/components/HypothesisApprovalCard.tsx b/console/src/features/investigation/components/HypothesisApprovalCard.tsx index 82b7da8..3623751 100644 --- a/console/src/features/investigation/components/HypothesisApprovalCard.tsx +++ b/console/src/features/investigation/components/HypothesisApprovalCard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { CheckCircle2, XCircle, Play } from "lucide-react"; +import { CheckCircle2, XCircle, Play, Ban } from "lucide-react"; import { cn } from "@/shared/lib/utils"; import { apiFetch } from "@/shared/lib/api"; @@ -13,15 +13,18 @@ interface HypothesisApprovalCardProps { investigationId: string; hypotheses: PendingHypothesis[]; onApproved: () => void; + onCancelled?: () => void; } export function HypothesisApprovalCard({ investigationId, hypotheses, onApproved, + onCancelled, }: HypothesisApprovalCardProps) { const [rejected, setRejected] = useState>(new Set()); const [submitting, setSubmitting] = useState(false); + const [cancelling, setCancelling] = useState(false); function toggleReject(id: string) { setRejected((prev) => { @@ -46,6 +49,14 @@ export function HypothesisApprovalCard({ onApproved(); } + async function handleCancel() { + setCancelling(true); + await apiFetch(`/investigate/${investigationId}/cancel`, { + method: "POST", + }); + onCancelled?.(); + } + const approvedCount = hypotheses.length - rejected.size; return ( @@ -59,14 +70,24 @@ export function HypothesisApprovalCard({ The Director formulated {hypotheses.length} hypotheses. Approve or reject each before experiments begin.

- +
+ + +
{hypotheses.map((h) => { diff --git a/console/src/features/investigation/components/HypothesisCard.tsx b/console/src/features/investigation/components/HypothesisCard.tsx index 1ba9385..256e0b6 100644 --- a/console/src/features/investigation/components/HypothesisCard.tsx +++ b/console/src/features/investigation/components/HypothesisCard.tsx @@ -1,7 +1,7 @@ import { ChevronDown, ChevronRight } from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "@/shared/lib/utils"; -import { MolViewer2D } from "@/features/molecule/components/MolViewer2D"; +import { MolViewer2D } from "@/features/molecule"; import type { Hypothesis } from "../types"; import { parseHypothesisStatement } from "../lib/parse-hypothesis"; diff --git a/console/src/features/investigation/components/InvestigationComparison.tsx b/console/src/features/investigation/components/InvestigationComparison.tsx index c1fc05e..12e258d 100644 --- a/console/src/features/investigation/components/InvestigationComparison.tsx +++ b/console/src/features/investigation/components/InvestigationComparison.tsx @@ -1,6 +1,19 @@ +import { useMemo } from "react"; +import Markdown from "react-markdown"; +import rehypeSanitize from "rehype-sanitize"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; import { cn } from "@/shared/lib/utils"; -import { MolViewer2D } from "@/features/molecule/components/MolViewer2D"; -import type { InvestigationDetail, CandidateRow } from "../types"; +import { MolViewer2D } from "@/features/molecule"; +import { VIZ_COLORS } from "@/features/visualization/theme"; +import type { InvestigationDetail, CandidateRow, Finding } from "../types"; interface InvestigationComparisonProps { invA: InvestigationDetail; @@ -12,16 +25,153 @@ function findShared(a: CandidateRow[], b: CandidateRow[]): string[] { return a.filter((c) => idsB.has(c.identifier)).map((c) => c.identifier); } +// ── Finding overlap detection ──────────────────────────────────────── + +function wordSet(text: string): Set { + return new Set(text.toLowerCase().split(/\s+/).filter(Boolean)); +} + +function jaccard(a: Set, b: Set): number { + if (a.size === 0 && b.size === 0) return 1; + let intersection = 0; + for (const w of a) { + if (b.has(w)) intersection++; + } + const union = a.size + b.size - intersection; + return union === 0 ? 0 : intersection / union; +} + +interface FindingDiff { + uniqueA: Finding[]; + shared: { a: Finding; b: Finding }[]; + uniqueB: Finding[]; +} + +function diffFindings(aFindings: Finding[], bFindings: Finding[]): FindingDiff { + const matchedB = new Set(); + const shared: { a: Finding; b: Finding }[] = []; + const uniqueA: Finding[] = []; + + for (const fa of aFindings) { + let matched = false; + for (let j = 0; j < bFindings.length; j++) { + if (matchedB.has(j)) continue; + const fb = bFindings[j]; + // Match by provenance if both have source_type + source_id + if (fa.source_type && fa.source_id && fb.source_type && fb.source_id) { + if (fa.source_type === fb.source_type && fa.source_id === fb.source_id) { + shared.push({ a: fa, b: fb }); + matchedB.add(j); + matched = true; + break; + } + } + // Fallback: title similarity + if (jaccard(wordSet(fa.title), wordSet(fb.title)) > 0.8) { + shared.push({ a: fa, b: fb }); + matchedB.add(j); + matched = true; + break; + } + } + if (!matched) uniqueA.push(fa); + } + + const uniqueB = bFindings.filter((_, i) => !matchedB.has(i)); + return { uniqueA, shared, uniqueB }; +} + +// ── Score chart data ───────────────────────────────────────────────── + +function buildScoreChartData( + sharedIds: string[], + candidatesA: CandidateRow[], + candidatesB: CandidateRow[], +): { data: Record[]; keys: string[] } | null { + const mapA = new Map(candidatesA.map((c) => [c.identifier, c])); + const mapB = new Map(candidatesB.map((c) => [c.identifier, c])); + const allKeys = new Set(); + const data: Record[] = []; + + for (const id of sharedIds) { + const a = mapA.get(id); + const b = mapB.get(id); + if (!a || !b) continue; + const entry: Record = { + name: a.name || id.slice(0, 20), + }; + for (const [k, v] of Object.entries(a.scores)) { + if (typeof v === "number") { + entry[`${k}_A`] = Number(v.toFixed(3)); + allKeys.add(k); + } + } + for (const [k, v] of Object.entries(b.scores)) { + if (typeof v === "number") { + entry[`${k}_B`] = Number(v.toFixed(3)); + allKeys.add(k); + } + } + data.push(entry); + } + + if (data.length === 0 || allKeys.size === 0) return null; + return { data, keys: [...allKeys] }; +} + +// ── Cost parsing ───────────────────────────────────────────────────── + +interface ParsedCost { + inputTokens: number; + outputTokens: number; + toolCalls: number; + totalCost: number; +} + +function parseCostData(cost_data: Record): ParsedCost | null { + const input = cost_data.input_tokens ?? cost_data.inputTokens; + const output = cost_data.output_tokens ?? cost_data.outputTokens; + const tools = cost_data.tool_calls ?? cost_data.toolCalls; + const total = cost_data.total_cost_usd ?? cost_data.totalCost; + if (input == null && output == null) return null; + return { + inputTokens: Number(input ?? 0), + outputTokens: Number(output ?? 0), + toolCalls: Number(tools ?? 0), + totalCost: Number(total ?? 0), + }; +} + +// ── Main component ─────────────────────────────────────────────────── + export function InvestigationComparison({ invA, invB }: InvestigationComparisonProps) { const sharedIdentifiers = findShared(invA.candidates, invB.candidates); + const findingDiff = useMemo(() => diffFindings(invA.findings, invB.findings), [invA.findings, invB.findings]); + const scoreChart = useMemo( + () => buildScoreChartData(sharedIdentifiers, invA.candidates, invB.candidates), + [sharedIdentifiers, invA.candidates, invB.candidates], + ); + const costA = parseCostData(invA.cost_data); + const costB = parseCostData(invB.cost_data); + + const supportingA = invA.findings.filter((f) => f.evidence_type === "supporting").length; + const supportingB = invB.findings.filter((f) => f.evidence_type === "supporting").length; return (
- {/* Stats bar */} -
- + {/* Enhanced stats bar */} +
+ + 0 ? Math.round((supportingA / invA.findings.length) * 100) : 0}% sup.)`} + /> - + 0 ? Math.round((supportingB / invB.findings.length) * 100) : 0}% sup.)`} + /> +
{/* Side-by-side prompts */} @@ -30,6 +180,14 @@ export function InvestigationComparison({ invA, invB }: InvestigationComparisonP
+ {/* Summary comparison */} + {(invA.summary || invB.summary) && ( +
+ + +
+ )} + {/* Side-by-side candidates */}
@@ -74,6 +232,69 @@ export function InvestigationComparison({ invA, invB }: InvestigationComparisonP
)} + {/* Score comparison chart */} + {scoreChart && ( +
+

+ Score Comparison +

+
+ + + + + + + {scoreChart.keys.map((key) => ( + + ))} + {scoreChart.keys.map((key) => ( + + ))} + + +
+
+ )} + + {/* Findings comparison */} + {(invA.findings.length > 0 || invB.findings.length > 0) && ( +
+

+ Findings Comparison +

+
+ + s.a)} + accent + /> + +
+
+ )} + + {/* Cost comparison */} + {(costA || costB) && ( +
+

+ Cost Comparison +

+
+ + +
+
+ )} + {/* Hypothesis summary */}
@@ -83,6 +304,8 @@ export function InvestigationComparison({ invA, invB }: InvestigationComparisonP ); } +// ── Inline helper components ───────────────────────────────────────── + function StatCard({ label, value, accent }: { label: string; value: string; accent?: boolean }) { return (
@@ -106,6 +329,17 @@ function PromptCard({ prompt, status }: { prompt: string; status: string }) { ); } +function SummaryCard({ summary, label }: { summary: string; label: string }) { + return ( +
+

{label}

+
+ {summary || "No summary available."} +
+
+ ); +} + function CandidateList({ title, candidates, @@ -143,6 +377,90 @@ function CandidateList({ ); } +function FindingColumn({ + label, + findings, + accent, +}: { + label: string; + findings: Finding[]; + accent?: boolean; +}) { + const EVIDENCE_BADGE: Record = { + supporting: "text-secondary", + contradicting: "text-destructive", + neutral: "text-muted-foreground", + }; + + return ( +
+

+ {label} +

+
+ {findings.length === 0 && ( +

None

+ )} + {findings.map((f, i) => ( +
+

{f.title}

+
+ + {f.evidence_type} + + {f.source_type && ( + + {f.source_type}{f.source_id ? `: ${f.source_id}` : ""} + + )} +
+
+ ))} +
+
+ ); +} + +function CostCard({ cost, label }: { cost: ParsedCost | null; label: string }) { + if (!cost) { + return ( +
+

{label}

+

No cost data

+
+ ); + } + return ( +
+

{label}

+
+
+ Input: + {cost.inputTokens.toLocaleString()} +
+
+ Output: + {cost.outputTokens.toLocaleString()} +
+
+ Tool calls: + {cost.toolCalls.toLocaleString()} +
+
+ Total: + ${cost.totalCost.toFixed(4)} +
+
+
+ ); +} + function HypothesisSummary({ title, hypotheses, @@ -162,8 +480,8 @@ function HypothesisSummary({ {Object.entries(counts).map(([status, count]) => ( {count} {status} diff --git a/console/src/features/investigation/components/InvestigationDiagram.tsx b/console/src/features/investigation/components/InvestigationDiagram.tsx index a9941ad..dac0ac1 100644 --- a/console/src/features/investigation/components/InvestigationDiagram.tsx +++ b/console/src/features/investigation/components/InvestigationDiagram.tsx @@ -33,7 +33,7 @@ export function InvestigationDiagram({ if (hypotheses.length === 0) { return ( -
+

No hypotheses to diagram yet

@@ -42,10 +42,10 @@ export function InvestigationDiagram({ } return ( -
+
+
} diff --git a/console/src/features/investigation/components/InvestigationList.tsx b/console/src/features/investigation/components/InvestigationList.tsx deleted file mode 100644 index e8fb2c0..0000000 --- a/console/src/features/investigation/components/InvestigationList.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useState } from "react"; -import { Link, useNavigate } from "@tanstack/react-router"; -import { Clock, FlaskConical, AlertCircle, CheckCircle2, GitCompareArrows, Square, CheckSquare } from "lucide-react"; -import { cn } from "@/shared/lib/utils"; -import type { InvestigationSummary } from "../types"; - -interface InvestigationListProps { - investigations: InvestigationSummary[]; -} - -const STATUS_CONFIG: Record< - string, - { icon: typeof Clock; label: string; className: string } -> = { - pending: { - icon: Clock, - label: "Pending", - className: "text-muted-foreground", - }, - running: { - icon: FlaskConical, - label: "Running", - className: "text-primary", - }, - completed: { - icon: CheckCircle2, - label: "Completed", - className: "text-secondary", - }, - failed: { - icon: AlertCircle, - label: "Failed", - className: "text-destructive", - }, -}; - -export function InvestigationList({ investigations }: InvestigationListProps) { - const [selected, setSelected] = useState>(new Set()); - const navigate = useNavigate(); - const completed = investigations.filter((inv) => inv.status === "completed"); - - if (investigations.length === 0) { - return null; - } - - function toggleSelect(id: string) { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else if (next.size < 2) { - next.add(id); - } - return next; - }); - } - - function handleCompare() { - const ids = [...selected]; - if (ids.length === 2) { - void navigate({ to: "/compare/$id1/$id2", params: { id1: ids[0], id2: ids[1] } }); - } - } - - return ( -
- {completed.length >= 2 && ( -
- -
- )} - {investigations.map((inv) => { - const config = STATUS_CONFIG[inv.status] ?? STATUS_CONFIG.pending!; - const StatusIcon = config.icon; - const isCompleted = inv.status === "completed"; - return ( -
- {completed.length >= 2 && isCompleted && ( - - )} - -
-

{inv.prompt}

-

- {new Date(inv.created_at).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - })} -

-
-
- {inv.candidate_count > 0 && ( - - {inv.candidate_count} candidates - - )} - - - {config.label} - -
- -
- ); - })} -
- ); -} diff --git a/console/src/features/investigation/components/InvestigationNode.tsx b/console/src/features/investigation/components/InvestigationNode.tsx new file mode 100644 index 0000000..5cb3c09 --- /dev/null +++ b/console/src/features/investigation/components/InvestigationNode.tsx @@ -0,0 +1,168 @@ +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { + BrainCircuit, + FlaskConical, + FileText, + Crosshair, + Wrench, + HelpCircle, + Info, + Search +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; +import type { InvestigationNodeData } from "../lib/diagram-builder"; + +// -- [CONSTANTS & DESCRIPTIONS] ------------------------------------------- + +const NODE_DESCRIPTIONS: Record = { + hypothesis: "A proposed explanation for a phenomenon. The AI generates these based on initial observations and refines them as evidence is gathered.", + experiment: "A structured test designed to validate or refute a potential hypothesis. Executes specific tools (like Python or Search) to gather empirical evidence.", + finding: "A discrete piece of evidence discovered during an experiment. It can either support, contradict, or be neutral towards a hypothesis.", + root: "The central research goal or question that initiated this entire investigation." +}; + +// -- [COMPONENT] ---------------------------------------------------------- + +export function InvestigationNode({ data }: NodeProps>) { + // Styles are passed pre-calculated in data for performance/consistency with layout engine + // or we can re-derive them here. The builder passes stroke, fill, text. + // However, the builder uses "status" to derive them. + // Let's use the passed colors if available, or fallback. + // The builder DOES pass stroke, fill, textColor. + const { stroke, fill, textColor, type, label, sublabel, tool_count, confidence, rationale, detail } = data; + + // Custom gradient logic based on type + let bgStyle = { + backgroundColor: "var(--color-surface)", + background: `linear-gradient(to bottom right, ${fill}, var(--color-surface))` + }; + + if (type === "root") { + bgStyle = { + backgroundColor: "var(--color-surface)", + background: "var(--color-surface)" + }; + } + + return ( + + + +
+ {/* 1. Default TOP Handle (Target for Vertical Flow) */} + + + {type === "root" ? ( +
+
+ + RESEARCH GOAL +
+ {label} +
+ ) : ( + <> + + {label} + +
+ + {sublabel} + + {tool_count !== undefined && tool_count > 0 && ( + + {tool_count} + + )} +
+ + )} + + {/* 2. RIGHT Handle (Source for Side Findings) */} + + + {/* 3. LEFT Handle (Target for Side Findings - Only Findings need this) */} + {type === "finding" && ( + + )} + + {/* 4. BOTTOM Handle (Source for Vertical Flow) */} + +
+
+ +
+ + {type === "hypothesis" && } + {type === "experiment" && } + {type === "finding" && } + {type === "root" && } + {type.toUpperCase()} + + {confidence !== undefined && ( + + Conf: {(confidence * 100).toFixed(0)}% + + )} +
+ +
+ {/* Educational Description */} +
+ + {NODE_DESCRIPTIONS[type]} +
+ +

{label}

+ + {rationale && ( +
+ + Rationale + + {rationale} +
+ )} + + {detail && ( +
+ + Evidence Detail + + {detail} +
+ )} + + {tool_count !== undefined && tool_count > 0 && ( +
+ + Executed {tool_count} automated tools +
+ )} +
+
+
+
+ ); +} diff --git a/console/src/features/investigation/components/InvestigationReport.tsx b/console/src/features/investigation/components/InvestigationReport.tsx index 8dcaf09..4075c7a 100644 --- a/console/src/features/investigation/components/InvestigationReport.tsx +++ b/console/src/features/investigation/components/InvestigationReport.tsx @@ -1,13 +1,19 @@ +import { useState } from "react"; import Markdown from "react-markdown"; +import rehypeSanitize from "rehype-sanitize"; +import { Link } from "@tanstack/react-router"; import { BookOpen, Download, + FileText, FlaskConical, + Loader2, Target, TestTube, Zap, } from "lucide-react"; -import { generateMarkdown, downloadMarkdown } from "../lib/export-markdown"; +import { downloadMarkdown } from "../lib/export-markdown"; +import { apiFetch } from "@/shared/lib/api"; import { cn } from "@/shared/lib/utils"; import { SectionHeader } from "@/shared/components/ui/SectionHeader"; import type { @@ -24,8 +30,10 @@ import { CandidateTable } from "./CandidateTable"; import { FindingsPanel } from "./FindingsPanel"; import { HypothesisBoard } from "./HypothesisBoard"; import { NegativeControlPanel } from "./NegativeControlPanel"; +import { ThreatAssessment } from "./ThreatAssessment"; interface InvestigationReportProps { + investigationId: string; prompt: string; summary: string; hypotheses: Hypothesis[]; @@ -47,6 +55,7 @@ const STATUS_COLORS: Record = { }; export function InvestigationReport({ + investigationId, prompt, summary, hypotheses, @@ -58,21 +67,20 @@ export function InvestigationReport({ domainConfig, validationMetrics, }: InvestigationReportProps) { - function handleExport() { - const md = generateMarkdown({ - prompt, - summary, - hypotheses, - experiments, - findings, - candidates, - negativeControls, - cost, - validationMetrics, - }); - const slug = prompt.slice(0, 40).replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase(); - const ts = new Date().toISOString().slice(0, 10); - downloadMarkdown(md, `${slug}_${ts}_report.md`); + const [exporting, setExporting] = useState(false); + + async function handleExport() { + setExporting(true); + try { + const paper = await apiFetch>( + `/investigate/${investigationId}/paper`, + ); + const slug = prompt.slice(0, 40).replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase(); + const ts = new Date().toISOString().slice(0, 10); + downloadMarkdown(paper.full_markdown, `${slug}_${ts}_paper.md`); + } finally { + setExporting(false); + } } return ( @@ -81,13 +89,24 @@ export function InvestigationReport({

Investigation Report

- +
+ + + View Paper + + +
{/* 1. Research Question */} @@ -114,7 +133,7 @@ export function InvestigationReport({ />
- {summary} + {summary}
@@ -190,7 +209,26 @@ export function InvestigationReport({ - {/* 7. Model Validation -- reuse NegativeControlPanel with pass/fail icons */} + {/* 7. Validity Threats -- causal findings */} + {(() => { + const threats = findings + .filter((f) => f.evidence && typeof f.evidence === "object") + .flatMap((f) => { + const ev = f.evidence as unknown as Record; + return Array.isArray(ev.threats) ? ev.threats : []; + }) + .filter( + (t): t is { type: string; severity: string; description: string; mitigation: string } => + typeof t === "object" && t !== null && "type" in t && "severity" in t, + ); + return threats.length > 0 ? ( +
+ +
+ ) : null; + })()} + + {/* 8. Model Validation -- reuse NegativeControlPanel with pass/fail icons */} {negativeControls.length > 0 && (
@@ -247,11 +285,12 @@ export function InvestigationReport({
- {cost.byModel && Object.keys(cost.byModel).length > 0 && ( + {cost.byRole && Object.keys(cost.byRole).length > 0 && (
+ @@ -260,9 +299,10 @@ export function InvestigationReport({ - {Object.entries(cost.byModel).map(([model, mc]) => ( - - + {Object.entries(cost.byRole).map(([role, mc]) => ( + + + diff --git a/console/src/features/investigation/components/LiteratureSurveyCard.tsx b/console/src/features/investigation/components/LiteratureSurveyCard.tsx new file mode 100644 index 0000000..6f3a011 --- /dev/null +++ b/console/src/features/investigation/components/LiteratureSurveyCard.tsx @@ -0,0 +1,63 @@ +import { Badge } from "@/shared/components/ui/badge"; +import type { LiteratureSurveyCompletedData } from "../types"; + +const PICO_COLORS: Record = { + population: "bg-blue-500/10 text-blue-500 border-blue-500/20", + intervention: "bg-amber-500/10 text-amber-500 border-amber-500/20", + comparator: "bg-purple-500/10 text-purple-500 border-purple-500/20", + outcome: "bg-green-500/10 text-green-500 border-green-500/20", +}; + +interface LiteratureSurveyCardProps { + data: LiteratureSurveyCompletedData; +} + +export function LiteratureSurveyCard({ data }: LiteratureSurveyCardProps) { + const picoEntries = Object.entries(data.pico).filter( + ([k, v]) => v && k !== "search_terms", + ); + + return ( +
+ {picoEntries.length > 0 && ( +
+ {picoEntries.map(([key, value]) => ( +
+ + {key[0]} + + {value} +
+ ))} +
+ )} + +
+ + {data.included_results}/{data.total_results} papers included + + | + + {data.search_queries} queries + + {data.evidence_grade && ( + <> + | + + {data.evidence_grade} Grade + + + )} +
+ + {data.assessment && ( +

+ {data.assessment} +

+ )} +
+ ); +} diff --git a/console/src/features/investigation/components/PhaseProgress.tsx b/console/src/features/investigation/components/PhaseProgress.tsx new file mode 100644 index 0000000..ef8e698 --- /dev/null +++ b/console/src/features/investigation/components/PhaseProgress.tsx @@ -0,0 +1,68 @@ +import { cn } from "@/shared/lib/utils"; +import type { PhaseInfo } from "../types"; + +const PHASES = [ + { number: 1, label: "Classification" }, + { number: 2, label: "Literature" }, + { number: 3, label: "Formulation" }, + { number: 4, label: "Testing" }, + { number: 5, label: "Controls" }, + { number: 6, label: "Synthesis" }, +]; + +interface PhaseProgressProps { + phase: PhaseInfo | null; + completed: boolean; +} + +export function PhaseProgress({ phase, completed }: PhaseProgressProps) { + const currentNumber = completed ? 7 : (phase?.phase ?? 0); + + return ( +
+
+ {PHASES.map((p, i) => ( +
+
+ {i < PHASES.length - 1 &&
} +
+ ))} +
+
+
+ {PHASES.map((p) => ( + + {p.label} + + ))} +
+ + {completed + ? "Investigation Complete" + : phase + ? phase.description + : "Initializing..."} + +
+
+ ); +} diff --git a/console/src/features/investigation/components/PhaseSection.tsx b/console/src/features/investigation/components/PhaseSection.tsx new file mode 100644 index 0000000..3d4262f --- /dev/null +++ b/console/src/features/investigation/components/PhaseSection.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Check, ChevronDown, ChevronRight } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +interface PhaseSectionProps { + phaseNumber: number; + currentPhase: number; + completed: boolean; + title: string; + summaryContent: ReactNode; + children: ReactNode; + defaultExpanded?: boolean; +} + +export function PhaseSection({ + phaseNumber, + currentPhase, + completed, + title, + summaryContent, + children, + defaultExpanded = false, +}: PhaseSectionProps) { + const effectiveCurrentPhase = completed ? 7 : currentPhase; + const isCompleted = phaseNumber < effectiveCurrentPhase; + const isActive = phaseNumber === effectiveCurrentPhase; + const isPending = phaseNumber > effectiveCurrentPhase; + + const [expanded, setExpanded] = useState(defaultExpanded); + const sectionRef = useRef(null); + + useEffect(() => { + if (isActive && sectionRef.current) { + sectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }, [isActive]); + + if (isPending) return null; + + if (isCompleted) { + return ( +
+ + {expanded && ( +
{children}
+ )} +
+ ); + } + + // Active phase + return ( +
+
+
+ {phaseNumber} +
+ + {title} + +
+
+ {children} +
+ ); +} diff --git a/console/src/features/investigation/components/PromptInput.test.tsx b/console/src/features/investigation/components/PromptInput.test.tsx index 4d53f5e..c5a5954 100644 --- a/console/src/features/investigation/components/PromptInput.test.tsx +++ b/console/src/features/investigation/components/PromptInput.test.tsx @@ -21,6 +21,13 @@ vi.mock("../hooks/use-credits", () => ({ }), })); +vi.mock("../hooks/use-upload", () => ({ + useUpload: () => ({ + mutateAsync: vi.fn(), + isError: false, + }), +})); + import { PromptInput } from "./PromptInput"; function renderWithProvider(props: { value: string; onChange: (v: string) => void }) { @@ -39,22 +46,13 @@ describe("PromptInput", () => { cleanup(); }); - it("renders tier selector buttons", () => { + it("renders default tier label", () => { const { container } = renderWithProvider({ value: "", onChange: vi.fn() }); const text = container.textContent ?? ""; - expect(text).toContain("Haiku"); - expect(text).toContain("Sonnet"); + // Default tier is Opus, shown on the popover trigger button expect(text).toContain("Opus"); }); - it("renders credit costs", () => { - const { container } = renderWithProvider({ value: "", onChange: vi.fn() }); - const text = container.textContent ?? ""; - expect(text).toContain("1cr"); - expect(text).toContain("3cr"); - expect(text).toContain("5cr"); - }); - it("renders textarea", () => { const { container } = renderWithProvider({ value: "", onChange: vi.fn() }); const textareas = container.querySelectorAll("textarea"); @@ -71,13 +69,14 @@ describe("PromptInput", () => { it("renders submit button", () => { const { container } = renderWithProvider({ value: "", onChange: vi.fn() }); - const submitBtn = container.querySelector('button[type="submit"]'); - expect(submitBtn).not.toBeNull(); - expect(submitBtn!.textContent).toContain("Start Investigation"); + // Submit button is type="button" with an ArrowUp icon + const buttons = container.querySelectorAll('button[type="button"]'); + expect(buttons.length).toBeGreaterThan(0); }); - it("renders director label", () => { + it("renders attach files button", () => { const { container } = renderWithProvider({ value: "", onChange: vi.fn() }); - expect(container.textContent).toContain("Director"); + const attachBtn = container.querySelector('button[title="Attach files"]'); + expect(attachBtn).not.toBeNull(); }); }); diff --git a/console/src/features/investigation/components/PromptInput.tsx b/console/src/features/investigation/components/PromptInput.tsx index 969b308..1187d04 100644 --- a/console/src/features/investigation/components/PromptInput.tsx +++ b/console/src/features/investigation/components/PromptInput.tsx @@ -1,13 +1,59 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; +import { ArrowUp, Paperclip, Brain } from "lucide-react"; import { useInvestigation } from "../hooks/use-investigation"; import { useCredits } from "../hooks/use-credits"; +import { FileUpload, type FileUploadRef, type UploadedFile } from "./FileUpload"; import type { DirectorTier } from "../types"; +import { Button } from "@/shared/components/ui/button"; +import { Textarea } from "@/shared/components/ui/textarea"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/components/ui/popover"; +import { cn } from "@/shared/lib/utils"; -const TIERS: { value: DirectorTier; label: string; credits: number }[] = [ - { value: "haiku", label: "Haiku", credits: 1 }, - { value: "sonnet", label: "Sonnet", credits: 3 }, - { value: "opus", label: "Opus", credits: 5 }, +const TIERS: { + value: DirectorTier; + label: string; + credits: number; + description: string; + models: { role: string; model: string }[]; +}[] = [ + { + value: "haiku", + label: "Haiku", + credits: 1, + description: "Fast & efficient for simple queries.", + models: [ + { role: "Director", model: "Haiku 4.5" }, + { role: "Researcher", model: "Haiku 4.5" }, + { role: "Summarizer", model: "Haiku 4.5" }, + ], + }, + { + value: "sonnet", + label: "Sonnet", + credits: 3, + description: "Balanced reasoning and speed.", + models: [ + { role: "Director", model: "Sonnet 4.5" }, + { role: "Researcher", model: "Sonnet 4.5" }, + { role: "Summarizer", model: "Haiku 4.5" }, + ], + }, + { + value: "opus", + label: "Opus", + credits: 5, + description: "Maximum reasoning power for complex tasks.", + models: [ + { role: "Director", model: "Opus 4.6" }, + { role: "Researcher", model: "Sonnet 4.5" }, + { role: "Summarizer", model: "Haiku 4.5" }, + ], + }, ]; interface PromptInputProps { @@ -20,18 +66,25 @@ export function PromptInput({ value, onChange }: PromptInputProps) { const mutation = useInvestigation(); const { data: creditData } = useCredits(); const [tier, setTier] = useState("opus"); + const [uploadedFiles, setUploadedFiles] = useState([]); + const fileUploadRef = useRef(null); - const selectedTier = TIERS.find((t) => t.value === tier)!; + const selectedTier = TIERS.find((t) => t.value === tier) || TIERS[2]; const isByok = creditData?.is_byok ?? false; const hasEnoughCredits = isByok || !creditData || creditData.credits >= selectedTier.credits; - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + function handleSubmit(e?: React.FormEvent) { + if (e) e.preventDefault(); if (!value.trim() || mutation.isPending || !hasEnoughCredits) return; + const fileIds = uploadedFiles.map((f) => f.file_id); mutation.mutate( - { prompt: value.trim(), director_tier: tier }, + { + prompt: value.trim(), + director_tier: tier, + ...(fileIds.length > 0 && { file_ids: fileIds }), + }, { onSuccess: (data) => { void navigate({ to: "/investigation/$id", params: { id: data.id } }); @@ -40,58 +93,158 @@ export function PromptInput({ value, onChange }: PromptInputProps) { ); } + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + } + + const [isDragging, setIsDragging] = useState(false); + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + setIsDragging(true); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + setIsDragging(false); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + // Cast FileList to File[] for the ref method. + // Actually ref.addFiles expects File[]. Array.from() works. + fileUploadRef.current?.addFiles(Array.from(e.dataTransfer.files)); + } + } + return ( -
-
Role Model Input Output
{model}
{role}{mc.model_display} {mc.input_tokens.toLocaleString()} {mc.output_tokens.toLocaleString()} {mc.calls}