diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..b455b9f86fed80ce69ae8b772a997bb9cd67ec60 --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# CCR Platform environment. Everything has a safe local-dev default; the +# variables under "Deployment" should be set explicitly on a real instance. + +# ---- Deployment (set these in production) ---- +# REQUIRED in production: sessions survive restarts only with a fixed secret. +# Generate one: python -c "import secrets; print(secrets.token_hex(32))" +# CCR_SESSION_SECRET= + +# Set to 1 when serving over HTTPS (marks cookies Secure). +# CCR_COOKIE_SECURE=1 + +# Anonymous-data TTL purge in hours. 0 disables (local-dev default); +# deployments should set 24 (PI retention decision, 2026-07-10). +# CCR_ANON_TTL_HOURS=24 + +# Preload the default embedding model at startup so the first run is fast. +# CCR_WARM_MODEL=1 + +# ---- Tiers and limits (defaults shown) ---- +# CCR_ANON_MAX_BYTES=2097152 # 2 MB anonymous upload cap +# CCR_ANON_MAX_ROWS=500 # anonymous row cap per file +# CCR_ANON_MAX_RUNS_PER_DAY=3 # anonymous runs/day, then sign-in +# CCR_USER_MAX_SAVED_RUNS=15 # saved-run cap for signed-in users + +# ---- Storage and processing ---- +# File storage backend: "local" (default; files under CCR_DATA_DIR) or "s3" +# (any S3-compatible store; Cloudflare R2 recommended - zero egress fees). +# The s3 path is production-ready; enabling it is config, not development. +# CCR_STORAGE=s3 +# CCR_S3_ENDPOINT=https://.r2.cloudflarestorage.com +# CCR_S3_BUCKET=ccr-platform +# CCR_S3_ACCESS_KEY_ID= +# CCR_S3_SECRET_ACCESS_KEY= + +# Where the DB, uploaded corpora, results, and embedding cache live (default: backend/data) +# CCR_DATA_DIR=/absolute/path +# Row ceiling for uploads (default 100000; hosted demo uses 20000) +# CCR_MAX_ROWS=20000 +# Corpus-embedding cache (default on; set 0 to disable) +# CCR_EMB_CACHE=1 + +# ---- Development / CI only ---- +# Force the deterministic fake embedder (never production) +# CCR_FAKE_EMBEDDINGS=1 + +# ---- Google sign-in via Supabase (optional; button hidden when unset) ---- +# Supabase dashboard > Project Settings > API. The anon key is public-facing +# by design; the service_role key is never used and never leaves the dashboard. +# SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co +# SUPABASE_ANON_KEY= +# Public base URL of this app (Google redirect target). +# CCR_APP_URL=http://127.0.0.1:8000 + +# ---- Phase 2 (not read yet; reserved names) ---- +# DATABASE_URL=postgresql://... +# ADMIN_EMAILS=devaanand@umass.edu,matari@umass.edu diff --git a/.gitignore b/.gitignore index 19acec3b0fc223fcc73657fc90e14bdbedf8b140..51756ec0d1bfaa388726f2dc29cec0f8736dc53d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules/ # os .DS_Store +.env diff --git a/DEPLOY.md b/DEPLOY.md index 138bfa6682cf56f0017c232d3231cd4364fabaf4..fa319d5e0775fdee4b7ecbfad8cfba06555a963e 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,81 +1,49 @@ -# Deploying the demo +# Deploying the dev instance (Hugging Face Space) -## Option A — Hugging Face Spaces (free, recommended for the demo) +The Space builds from this repo's Dockerfile. One-time setup lives in the +Space settings; after that, deploys are just `git push hf main`. -Free Docker Spaces: 2 vCPU / 16 GB RAM, no credit card. Bonus: the original -CCR online tool lives on HF Spaces, so the prototype sits where the CCR -community already works. +## Space secrets (Settings > Variables and secrets) -1. Create the Space at https://huggingface.co/new-space → - SDK: **Docker** → visibility: Public → name: `ccr-platform`. +| Secret | Value | +|---|---| +| CCR_SESSION_SECRET | `python3 -c "import secrets; print(secrets.token_hex(32))"` | +| SUPABASE_URL | from Supabase > Project Settings > API | +| SUPABASE_ANON_KEY | from the same page (anon public key, NOT service_role) | +| CCR_APP_URL | https://devaanand-ccr-platform.hf.space | +| CCR_COOKIE_SECURE | 1 | -2. HF reads deployment config from YAML frontmatter at the top of the - Space's `README.md`. Add this block (top of the file) before pushing: +Retention (CCR_ANON_TTL_HOURS=24) and model pre-warm are already defaults in +the Dockerfile. - ```yaml - --- - title: CCR Platform - emoji: 🧭 - colorFrom: red - colorTo: gray - sdk: docker - app_port: 7860 - pinned: false - --- - ``` +## Supabase setup for Google sign-in (one time) -3. Push this repo to the Space: +1. supabase.com > New project (free tier). +2. Authentication > Providers > Google > Enable. Copy the shown callback URL + (https://PROJECT_REF.supabase.co/auth/v1/callback). +3. console.cloud.google.com > OAuth consent screen (External) > Credentials > + Create OAuth client ID (Web application) > add the Supabase callback URL as + an authorized redirect URI. Paste client id/secret back into the Supabase + Google provider form. +4. Authentication > URL Configuration: add BOTH redirect URLs: + - http://127.0.0.1:8000/api/auth/google/callback + - https://devaanand-ccr-platform.hf.space/api/auth/google/callback +5. Project Settings > API: copy the Project URL and anon key into the Space + secrets (and your local .env). - ```bash - git remote add hf https://huggingface.co/spaces//ccr-platform - git push hf main - ``` - - First build takes ~5–10 min (model bakes into the image). Watch the - build logs in the Space's "Logs" tab. - -4. Optional hardening for the public instance — in Space Settings → - Variables, set `CCR_MAX_ROWS=20000` (tighter ceiling than the - 100k default while strangers can reach it). - -Notes: -- Storage is **ephemeral** — uploads/results vanish on restart or rebuild. - Fine for a demo; the email and the in-app welcome text both say so. -- Free Spaces sleep after ~48h without traffic. Visit the URL the - evening before and the morning of the interview so it's warm. -- The direct app URL (no HF frame) is - `https://-ccr-platform.hf.space` — send that one. - -## Option B — Google Cloud Run (few dollars, more "prod-like" URL) +## Push ```bash -gcloud run deploy ccr-platform \ - --source . \ - --region us-central1 \ - --allow-unauthenticated \ - --memory 2Gi \ - --cpu 2 \ - --min-instances 1 \ - --max-instances 1 \ - --concurrency 20 +git push origin main # GitHub +git push hf main # Hugging Face Space (rebuilds + redeploys) ``` -- `--min-instances 1`: no cold starts while he plays with it (~a few - dollars for the week; delete the service after the process ends). -- `--max-instances 1`: SQLite + in-process queue assume one instance — - documented demo trade-off, not an oversight. - -## Pre-send checklist (either host) +The hf remote has no stored token; use your HF username and a WRITE token as +the password when prompted (or a credential helper). -1. Open the URL in an **incognito window and on your phone (off Wi-Fi)**. -2. Full run: new project → upload `sample_data/sample_corpus.csv` → - Satisfaction with Life → Run → results render → Export CSV downloads. -3. Second run (Individualism) finishes in seconds (model + item cache warm). -4. Upload your own messy CSV (Excel export with a BOM, or semicolon- - delimited) — parses, and any fallback is flagged in the UI. -5. Upload rejects a bogus file (.txt/.exe) with a clean error. -6. Refresh the page — SPA loads, project still listed. -7. Morning of the interview: open the URL once (warm the instance), - re-run step 2 quickly. +## Caveats of the free dev instance -If anything fails, fix before sending. No link is better than a broken link. +- Ephemeral disk: SQLite resets on rebuild/restart. Google users are recreated + on next sign-in automatically; password accounts must re-register. Fine for + feedback; a persistent volume or Postgres arrives with the launch decision. +- The Space sleeps after ~48 h idle; first visit wakes it (~1 min). diff --git a/Dockerfile b/Dockerfile index 35a27e0a78ca901492eee475c271d5196331b303..13d232133ecdd2dc6bb6468274974f86b973771b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,46 @@ -# CCR Platform — Cloud Run / container deployment. -# The embedding model is baked into the image so the first request -# doesn't trigger a ~90 MB download (critical for demo cold starts). +# CCR Platform - single-container deployment (one deployable unit: FastAPI +# serves both the JSON API and the prebuilt React SPA from backend/static). +# +# Build: docker build -t ccr-platform . +# Ephemeral demo: docker run -p 7860:7860 ccr-platform +# Persistent: docker run -p 7860:7860 \ +# -e CCR_SESSION_SECRET=$(python -c "import secrets;print(secrets.token_hex(32))") \ +# -e CCR_DATA_DIR=/data -e CCR_COOKIE_SECURE=1 \ +# -v ccr_data:/data ccr-platform +# +# NOTE: run `npm run build` in frontend/ before building the image - the +# committed backend/static is what ships (no Node stage; keeps HF Spaces +# builds fast and the image small). FROM python:3.11-slim -# Data + caches in /tmp so the container runs under any UID -# (Hugging Face Spaces runs containers as a non-root user). +# Defaults favor a hosted instance: retention purge on, model pre-warmed. +# Data dir defaults to /tmp so the container runs under any UID (HF Spaces); +# persistent deployments override CCR_DATA_DIR to a mounted volume. ENV PYTHONUNBUFFERED=1 \ HF_HOME=/opt/hf-cache \ - CCR_DATA_DIR=/tmp/ccr-data + CCR_DATA_DIR=/tmp/ccr-data \ + CCR_ANON_TTL_HOURS=24 \ + CCR_WARM_MODEL=1 WORKDIR /srv COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Pre-download ALL offered models into the image layer — a user picking a -# non-default model must not trigger a multi-hundred-MB download mid-job -# (looks like a hang). Make the cache usable by any runtime UID. +# Bake the DEFAULT model (MiniLM, ~90 MB - the CCR reference model) into the +# image so the first run never stalls on a download. The E5 models are large +# (1+ GB) and lazy-load into HF_HOME on first use instead; the dir stays +# writable for any runtime UID. RUN python -c "from sentence_transformers import SentenceTransformer; \ - [SentenceTransformer(m) for m in ( \ - 'sentence-transformers/all-MiniLM-L6-v2', \ - 'sentence-transformers/all-mpnet-base-v2', \ - 'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')]" \ + SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')" \ && chmod -R 777 /opt/hf-cache COPY backend/app ./app COPY backend/static ./static +# registry.py/construct_lib.py resolve packages/ two levels above app/ +# (= "/" here), so /packages is exactly where they look. +COPY packages /packages -# Demo note: SQLite + uploads live on the container's ephemeral disk — -# data resets on restart/redeploy. Acceptable for a demo; use -# Postgres + S3/GCS object storage before any real use. EXPOSE 7860 CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"] diff --git a/MANUAL_TESTING.md b/MANUAL_TESTING.md new file mode 100644 index 0000000000000000000000000000000000000000..f7f95eab7d004fd6a505ff4c339f9e518a99f452 --- /dev/null +++ b/MANUAL_TESTING.md @@ -0,0 +1,163 @@ +# Manual testing guide + +Everything built so far, as click-through scenarios. Each scenario says what to +do and exactly what you should see. Files referenced live in `sample_data/` +(see `sample_data/README.md` for what each one triggers). + +## 0. Setup + +```bash +cd backend +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 +``` + +Open http://127.0.0.1:8000. First start downloads MiniLM (~90 MB) on the first +real run; set `CCR_WARM_MODEL=1` to preload it at startup instead. + +To test quickly without models: `CCR_FAKE_EMBEDDINGS=1 uvicorn ...` (scores are +fake but every flow works; never use for real analysis). + +## 1. Projects and sidebar + +1. Create three projects. They appear under "Today", newest activity first. +2. Type in the sidebar search box: list filters as you type. +3. Archive a project (project header > Archive): it moves into the collapsed + "Archived" group; Unarchive brings it back. No data is lost either way. +4. Delete a project: requires typing the project name; removes its datasets, + runs, and files permanently. + +## 2. Upload paths (Step 1 card) + +| Upload | Expect | +|---|---| +| `sample_corpus.csv` | Parses, 60 rows, `text` column suggested | +| `multi_column_demo.csv` | 5 columns; `comment_text` marked "(suggested)" | +| `semicolon_delimited_demo.csv` | Parses into exactly id + text (commas inside texts intact) | +| `latin1_encoding_demo.csv` | Parses with a ⚠ note: decoded as latin-1; fiancée/café render correctly | +| `xlsx_upload_demo.xlsx` | Parses like a CSV | +| a `.txt` or `.pdf` file | Rejected: unsupported file type | + +Anonymous limits (signed out): the Step 1 hint shows 2 MB / 500 rows and says +uploads are deleted after analysis. Upload `large_demo.csv` (800 rows): rejected +with a "Sign in (top right)" message. Sign in and retry: accepted. + +## 3. Construct selection (Step 2 card) + +1. Open the picker: search field + panel below it, library grouped by category, + with "Recently used" pinned on top after your first runs. +2. Type "GAD" or "empathy": matches by name and category; Arrow keys + Enter work. +3. Select any imported construct: items listed, plus the "not yet verified + verbatim" notice (expected for the whole imported library for now). + +### Custom construct, typed + +1. "+ Custom construct" > name it, paste items one per line. +2. Append `(R)` to one line: the form shows "1 item(s) marked reverse-scored". +3. Save: it appears in the picker under "My custom constructs"; run metadata + will carry the reverse flag (check via Results > metadata download). + +### Custom construct, from file (new) + +1. "+ Custom construct" > "Upload items from CSV/XLSX". +2. Try a CSV with `item,reverse` columns (1/true/yes/R = reverse) or a + single-column file with `(R)` markers. +3. Expect: items fill the textarea ((R) appended where flagged), the filename + becomes the suggested name, and parse notes list skipped blanks/duplicates. + Nothing is saved until you review and press Save. Item files are never + retained on the server. + +## 4. Language, models, and warnings (Step 3 card + results) + +Run each of these and open the results page; the amber warnings panel should +show exactly: + +| Corpus | Selection | Expected warnings | +|---|---|---| +| `warnings_showcase.csv` | en + MiniLM | EMPTY_ROWS_DROPPED (2), DUPLICATE_TEXTS (2), TEXT_TOO_SHORT (3), TEXTS_MAYBE_TRUNCATED (2); no language warnings | +| `french_demo.csv` | en + MiniLM | LANGUAGE_MISMATCH (detected fr, 100%) | +| `french_demo.csv` | fr + MiniLM | MODEL_LANGUAGE_UNSUPPORTED | +| `french_demo.csv` | fr + Multilingual E5 | no language warnings | +| `mixed_language_demo.csv` | en + MiniLM | LANGUAGE_UNCERTAIN (majority 50%) | +| `long_documents_demo.csv` | en + MiniLM | TEXTS_MAYBE_TRUNCATED (4) + LANGUAGE_UNCERTAIN (only 10 rows, below the 20-row minimum - by design) | + +Warnings are per-run snapshots: changing language/model requires a NEW run; +old result pages don't update. + +## 5. Results and reproducibility + +1. Run `moral_foundations_demo.csv` against two different MFQ-2 foundations: + top texts change per foundation; the 6 neutral rows sink to the bottom. +2. Results page: histogram, mean/SD/min/max, per-item loadings, top/bottom texts. +3. Downloads: results CSV (input columns + sim_item_N + ccr_score), metadata + JSON (model revision, construct snapshot + item hash, language block, + environment pins), reproduction script + requirements file. +4. Reproduction check: `pip install -r requirements-repro.txt`, then + `python reproduce_analysis.py your_corpus.csv` on a machine with no platform + access; values should match the export (target ~1e-5 with real models). + +## 6. Accounts + +1. Sign in (top right) > "Create a free account" > email + password (min 8 chars). +2. You're signed in immediately; header shows your name. +3. Sign out, sign back in; wrong password gives "Incorrect email or password"; + registering the same email again gives "already exists". +4. Email is case-insensitive. There is no self-service password reset yet + (interim local accounts; Google/Supabase swap planned) - reset = admin action. + +## 7. Anonymous tiers (test signed OUT) + +1. Upload caps: see section 2. +2. Run limit: run 3 analyses (default). The Step 3 card counts "X of 3 free + runs used today". The 4th run is refused with a sign-in prompt (HTTP 429). + Counter resets next day (UTC). Signing in removes the limit. +3. Delete-after-analysis: run any corpus, open results (fine, downloadable), + note the info warning "uploaded file was deleted after this analysis". + Re-running that same corpus: refused ("upload again, or sign in"). +4. TTL purge: with `CCR_ANON_TTL_HOURS=24` (deployment default; 0 = off in + local dev), anonymous projects older than 24h are deleted entirely, + startup + hourly. + +## 8. Signed-in tier + +1. Sign in, upload, run: no ANONYMOUS_DATA_REMOVED warning; re-running the same + corpus works (file kept). +2. Saved-run cap: Step 3 card shows "N of 15 saved runs used". At the cap, new + runs are refused until you delete old runs/projects (nothing is auto-deleted). +3. Ownership: your projects are invisible to signed-out visitors and other + accounts (they get 403 on any modification). Anonymous projects stay shared. + +## 9. Performance behaviors + +1. Corpus-embedding cache: run the SAME corpus with a second construct + (signed in, same model): the run skips document embedding and completes in + seconds; metadata shows `"doc_embeddings_from_cache": true`. +2. Duplicate texts are embedded once (`warnings_showcase.csv` has 2 dupes): + identical scores for identical texts, less compute. +3. API responses are gzip-compressed (check the response headers). + +## 10. Robustness + +1. Restart the server mid-run: the orphaned job is marked failed with an + explanation, never stuck at "running". +2. A DB from an older version gains new columns automatically at startup + (additive auto-migration) - no more "no such column" 500s. +3. Tampered session cookie = treated as signed out, no error. + +## 11. Deployment (container) + +```bash +cd frontend && npm run build && cd .. +docker build -t ccr-platform . +docker run -p 7860:7860 \ + -e CCR_SESSION_SECRET=$(python3 -c "import secrets;print(secrets.token_hex(32))") \ + -e CCR_DATA_DIR=/data -e CCR_COOKIE_SECURE=1 \ + -v ccr_data:/data ccr-platform +``` + +Checklist before giving the URL to real users: +- [ ] `CCR_SESSION_SECRET` set (sessions survive restarts) +- [ ] `CCR_COOKIE_SECURE=1` (HTTPS only) +- [ ] `CCR_DATA_DIR` on a persistent volume (default /tmp is ephemeral) +- [ ] `CCR_ANON_TTL_HOURS=24` (default in the image) +- [ ] Smoke test: sections 2, 4, 6, 7 above diff --git a/README.md b/README.md index 2181c5a4206e72b75f7762246383f6c958b7d2df..a617dd8b5eb03e33403f7046834a940e10683e1b 100644 --- a/README.md +++ b/README.md @@ -10,87 +10,49 @@ pinned: false # CCR Platform -A web platform for **Contextualized Construct Representations (CCR)** — theory-driven psychological text analysis ([Atari, Omrani, et al.](https://github.com/Ali-Omrani/CCR); [Chen et al., EMNLP 2024](https://aclanthology.org/2024.emnlp-main.151/)). - -Researchers upload a text corpus, select (or define) a psychological construct backed by a validated self-report scale, run a CCR analysis with a locally-hosted sentence-embedding model, inspect the results, and export scores — with a reproducibility record attached to every run. - -**Method in one line:** embed the validated scale items and the texts with a contextual language model; the cosine similarity between a text and each item is the text's *loading* on the construct; the mean loading is its CCR score. - -## Quickstart - -Requires Python 3.10+. No Node needed — the dashboard ships prebuilt. - -```bash -./run.sh -# then open http://127.0.0.1:8000 -``` - -First run creates a virtualenv and installs dependencies; the default embedding model (~90 MB) downloads on first analysis. To verify the install end-to-end: +A web platform for **Contextualized Construct Representations (CCR)** - theory-driven +psychological text analysis ([Atari, Omrani, et al.](https://github.com/Ali-Omrani/CCR); +[EMNLP 2024](https://aclanthology.org/2024.emnlp-main.151/)). Built for the Culture and +Morality Lab (UMass Amherst); this instance is the lab's **dev/testing environment**. + +Upload a corpus (CSV/XLSX), pick a validated construct from the library or define your +own (typed or uploaded from a file), choose a language and embedding model, run the +analysis, inspect results (distributions, per-item loadings, top/bottom texts, +data-quality warnings), and export everything - including a Python script that +reproduces the run on any machine. + +## Features + +- Anonymous try-it tier: 3 runs/day, uploads deleted right after analysis, sessions + purged after 24 h. Free accounts (email/password, optional Google sign-in) lift + limits and keep your work (15 saved runs). +- Construct library (versioned, append-only, item-hashed) + custom constructs with + reverse-scored flags; searchable grouped picker. +- Model registry: MiniLM default (the CCR reference model), E5-large-v2, + Multilingual-E5; E5 prefix policy handled automatically; language coverage warnings. +- Structured data-quality warnings (language mismatch/uncertainty, short texts, + truncation, duplicates, encoding fallback) - stable machine-readable codes. +- Per-run reproducibility: metadata JSON + offline-runnable script + pinned + requirements. Corpus-embedding cache makes re-runs on the same corpus near-instant. +- Storage: local disk by default; S3-compatible (Cloudflare R2) via env config. + +## Run locally ```bash -source backend/.venv/bin/activate -python scripts/verify_install.py -``` - -**Try it:** create a project → upload `sample_data/sample_corpus.csv` (60 synthetic texts) → choose *Satisfaction with Life* → Run. Then re-run the same corpus against *Individualism* vs *Collectivism* and compare the top-scoring texts. - -## What the platform adds over the existing CCR tools - -The published [R/Python packages](https://github.com/Ali-Omrani/CCR) and the single-run web demo cover one-off analyses. This platform adds the workflow around the method: **projects** that persist corpora and runs, a **construct library** of validated scales (plus custom constructs), **async jobs** with live progress on large corpora, a **results dashboard** (score distribution, per-item loadings, highest/lowest-scoring texts for face-validity checks), **CSV export** in the same shape as `ccr_wrapper` output, and a **reproducibility record** (model + version, item hash, package versions, timestamps) downloadable per run. - -## Architecture - -``` -Browser (React SPA, prebuilt → served by FastAPI) - │ REST /api/* -FastAPI (backend/app/main.py) - │── SQLite (projects, corpora, constructs, jobs) backend/data/ccr.db - │── File storage (uploaded corpora, result CSVs) backend/data/ - └── Background jobs (backend/app/jobs.py) - └── CCR engine (backend/app/ccr.py) - └── sentence-transformers (local, pinned) +cd backend +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 --env-file ../.env ``` -| Component | Choice | Why (and what it's not) | -|---|---|---| -| Embeddings | Local `sentence-transformers`, default `all-MiniLM-L6-v2` | Matches published CCR; pinned weights = reproducible results; text never leaves the deployment (IRB-friendly when self-hosted). Not an embeddings API: per-call cost, data leaves your control, models get deprecated mid-study. | -| Database | SQLite | Right-sized for single-node, few writers. Schema is Postgres-portable; the upgrade trigger is concurrent multi-user writes. | -| Job execution | FastAPI `BackgroundTasks`, state in DB | Zero extra infrastructure; lab-scale corpora embed in seconds–minutes. Known limits (no restart survival, no retries) are accepted MVP trade-offs; upgrade trigger to Celery+Redis is long/frequent jobs — the API contract doesn't change because job state already lives in the DB. | -| Model dependency | Injected `EmbeddingBackend` interface | Tests/CI run a deterministic hash embedder (no torch), so the full pipeline is testable in seconds. Production backend is swappable per job. | -| Frontend | React (Vite), served as static files by the API | Single deployable, no CORS in production, no Node required to run. | +Open http://127.0.0.1:8000. See `MANUAL_TESTING.md` for a full click-through test +script and `sample_data/README.md` for what each sample file demonstrates. +Configuration: copy `.env.example` to `.env` and fill what you need. -## Processing robustness (bring your own corpus) - -Research files are messy, so ingestion is tolerant by design: encoding fallback (UTF-8 with BOM → latin-1, with a user-facing note when fallback was needed), delimiter sniffing (`,` `;` tab `|`), ragged-row skipping, a configurable row ceiling (`CCR_MAX_ROWS`, default 100k), and a text-column suggestion heuristic. The exact parse configuration (format, encoding, delimiter) is stored per corpus and echoed into each run's reproducibility record. Runs execute on a dedicated worker queue with persisted state — jobs orphaned by a restart are marked failed with an explanation instead of hanging — and results carry data-quality notes (empty rows dropped, duplicates detected, texts likely truncated by the model's token window) so silent data issues become visible ones. - -## Reproducibility & data handling - -Every run records: model name + `sentence-transformers` version, embedding dimension, SHA-256 of the exact item wordings, text column, row counts (including empty rows dropped), timestamps, and library versions — downloadable as JSON next to the results CSV. Exports mirror the `ccr_wrapper` output shape (input columns + `sim_item_i` + `ccr_score`) so they drop into existing CCR workflows. - -Processing is self-contained: embeddings are computed on the server running the app — text is never sent to third-party AI APIs. Run locally (`./run.sh`) and corpora never leave your machine, which is the recommended mode for sensitive data. Uploaded corpora and results live in the data directory (`backend/data/` locally; ephemeral on the hosted demo, which may reset at any time — don't upload sensitive or identifiable data there). - -## Construct library — verify before research use - -The seeded scales (SWLS; MFQ Care & Fairness; Triandis & Gelfand Individualism/Collectivism) carry citations, but item wordings must be **verified verbatim against the original publications before research use** — CCR's validity rests on using the validated instrument as published. - -## Known limitations / roadmap - -- **Method nuance:** cosine similarity captures construct *relatedness* more than stance — a text lamenting life dissatisfaction can sit near SWLS items in embedding space. Reverse-scored items need care, and results should be validated against human-annotated subsets for new constructs/corpora. -- No auth/multi-user yet (single-lab, local deployment); add before any public hosting, along with per-user quotas. -- `BackgroundTasks` → Celery+Redis when corpora grow; SQLite → Postgres with multi-user concurrency; local files → S3/GCS if deployed off-machine. -- Embedding cache keyed on (model, item-set hash) to make repeated runs on the same construct instant. - -## Development - -```bash -# backend tests (fast — no ML deps needed) -cd backend && pip install -r requirements-dev.txt && python -m pytest tests/ -q - -# frontend dev server (proxies /api to :8000) -cd frontend && npm install && npm run dev - -# rebuild the shipped dashboard -cd frontend && npm run build # outputs to backend/static/ -``` +## Notes -Tests cover the CCR engine (determinism, normalization, scoring) and the full API flow: project → upload → job lifecycle → results summary → export shape → validation errors. +- Do not upload sensitive or identifiable data to this shared dev instance; anonymous + storage is ephemeral and the instance may reset. +- The construct library here carries the 5 original seed scales; the lab's full + imported collection ships separately pending a redistribution-rights decision. +- Tests: `cd backend && CCR_FAKE_EMBEDDINGS=1 python -m pytest -q` (64 tests, no ML + downloads needed). diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..c8eb78a834b63f72786bef074b60071fb1640b58 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,159 @@ +"""Accounts, sessions, and usage tiers. + +Local email+password accounts - the "best cheap option available now" (Deva, +2026-07-11): zero external dependencies, zero cost, real password security via +stdlib scrypt. This deliberately does NOT implement email verification or +self-service password reset; at lab scale a reset is an admin action. The +managed-provider swap (Supabase: Google + email/password, design doc §8) +replaces token creation/verification here - get_current_user() stays the only +integration point the rest of the app knows about. + +Sessions: HMAC-signed cookie carrying {uid, email, name}. Secret from +CCR_SESSION_SECRET (REQUIRED in production - random per process otherwise, +which signs everyone out on restart). + +Anonymous usage tiers (PI decisions, 2026-07-10): + * upload caps (bytes/rows), + * run limit per day (signed cookie counter - a nudge toward accounts, not a + security boundary; clearing cookies evades it and that is acceptable), + * data removed after analysis (see retention.py). +Signed-in users: caps lifted, runs persist up to a saved-run cap. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import re +import secrets +from datetime import datetime, timezone + +from fastapi import Request + +COOKIE_NAME = "ccr_session" +RUNS_COOKIE_NAME = "ccr_runs" +_SECRET = (os.environ.get("CCR_SESSION_SECRET") or secrets.token_hex(32)).encode() + +ANON_MAX_BYTES_DEFAULT = 2 * 1024 * 1024 +ANON_MAX_ROWS_DEFAULT = 500 +ANON_MAX_RUNS_PER_DAY_DEFAULT = 3 +USER_MAX_SAVED_RUNS_DEFAULT = 15 +ANON_TTL_HOURS_DEFAULT = 0 # 0 = purge disabled (local dev); deployments set 24 + +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +MIN_PASSWORD_LEN = 8 + + +# ------------------------------------------------------------- env knobs +def anon_max_bytes() -> int: + return int(os.environ.get("CCR_ANON_MAX_BYTES", ANON_MAX_BYTES_DEFAULT)) + + +def anon_max_rows() -> int: + return int(os.environ.get("CCR_ANON_MAX_ROWS", ANON_MAX_ROWS_DEFAULT)) + + +def anon_max_runs_per_day() -> int: + return int(os.environ.get("CCR_ANON_MAX_RUNS_PER_DAY", ANON_MAX_RUNS_PER_DAY_DEFAULT)) + + +def user_max_saved_runs() -> int: + return int(os.environ.get("CCR_USER_MAX_SAVED_RUNS", USER_MAX_SAVED_RUNS_DEFAULT)) + + +def anon_ttl_hours() -> int: + return int(os.environ.get("CCR_ANON_TTL_HOURS", ANON_TTL_HOURS_DEFAULT)) + + +def cookies_secure() -> bool: + """Set CCR_COOKIE_SECURE=1 behind HTTPS in production.""" + return os.environ.get("CCR_COOKIE_SECURE") == "1" + + +# ---------------------------------------------------------- passwords +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=64) + return f"scrypt${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, stored: str) -> bool: + try: + algo, salt_hex, digest_hex = stored.split("$") + if algo != "scrypt": + return False + digest = hashlib.scrypt( + password.encode(), salt=bytes.fromhex(salt_hex), n=16384, r=8, p=1, dklen=64 + ) + return hmac.compare_digest(digest.hex(), digest_hex) + except Exception: + return False + + +def valid_email(email: str) -> bool: + return bool(_EMAIL_RE.match(email.strip().lower())) + + +# ------------------------------------------------- signed cookie payloads +def _sign(payload: bytes) -> str: + return hmac.new(_SECRET, payload, hashlib.sha256).hexdigest() + + +def sign_payload(data: dict) -> str: + payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode() + return f"{payload}.{_sign(payload.encode())}" + + +def verify_payload(token: str | None) -> dict | None: + if not token or "." not in token: + return None + payload, signature = token.rsplit(".", 1) + if not hmac.compare_digest(signature, _sign(payload.encode())): + return None + try: + data = json.loads(base64.urlsafe_b64decode(payload.encode()).decode()) + return data if isinstance(data, dict) else None + except Exception: + return None + + +# ------------------------------------------------------------- sessions +def create_session_token(user_id: str, email: str, name: str) -> str: + return sign_payload({"uid": user_id, "email": email, "name": name}) + + +def get_current_user(request: Request) -> dict | None: + """THE auth integration point (design doc §8). A managed provider (Supabase) + replaces this body with provider-session verification; callers only ever see + {"id", "email", "name", "tier"} or None.""" + data = verify_payload(request.cookies.get(COOKIE_NAME)) + if not data or "uid" not in data: + return None + return { + "id": data["uid"], + "email": data.get("email", ""), + "name": data.get("name", ""), + "tier": "member", + } + + +# ------------------------------------------- anonymous daily run counter +def _today() -> str: + return datetime.now(timezone.utc).date().isoformat() + + +def runs_used_today(request: Request) -> int: + data = verify_payload(request.cookies.get(RUNS_COOKIE_NAME)) + if not data or data.get("d") != _today(): + return 0 # missing, tampered, or from a previous day - counter resets + try: + return max(0, int(data.get("n", 0))) + except (TypeError, ValueError): + return 0 + + +def run_counter_token(count: int) -> str: + return sign_payload({"d": _today(), "n": int(count)}) diff --git a/backend/app/auth_google.py b/backend/app/auth_google.py new file mode 100644 index 0000000000000000000000000000000000000000..3d84c86e4e1b442f2f6ac526576108818cd2b884 --- /dev/null +++ b/backend/app/auth_google.py @@ -0,0 +1,99 @@ +"""Google sign-in via Supabase Auth (server-side PKCE flow). + +Feature-flagged: everything here is inert until SUPABASE_URL and +SUPABASE_ANON_KEY are set, so local dev and tests run unchanged without any +Supabase project. When configured, the flow is: + + 1. GET /api/auth/google/login -> redirect to Supabase's Google authorize + URL with a PKCE challenge; the verifier rides in a short-lived signed + cookie (never stored server-side). + 2. Google -> Supabase -> GET /api/auth/google/callback?code=... + 3. The backend exchanges code+verifier for the Supabase user (stdlib + urllib - no new dependencies), finds-or-creates a local User row by + email, and issues OUR normal session cookie (auth.py). + +Design consequence: Supabase verifies identity at sign-in time only; the +session, tiers, and ownership model are exactly the same as email/password +accounts. Google users have an empty password_hash and cannot password-login +(a clear message says to use Google). Because users are re-created on next +sign-in by email, an ephemeral-disk dev instance losing its SQLite file is a +nuisance, not a lockout. + +No frontend SDK: the button is a plain link, keeping the react+react-dom-only +dependency rule intact. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import secrets +import urllib.error +import urllib.parse +import urllib.request + +VERIFIER_COOKIE = "ccr_pkce" +VERIFIER_TTL_SECONDS = 600 + + +def configured() -> bool: + return bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_ANON_KEY")) + + +def _supabase_url() -> str: + return os.environ["SUPABASE_URL"].rstrip("/") + + +def app_url() -> str: + """Public base URL of THIS app (redirect target). Local default matches + the dev server; deployments set CCR_APP_URL.""" + return os.environ.get("CCR_APP_URL", "http://127.0.0.1:8000").rstrip("/") + + +def begin() -> tuple[str, str]: + """Return (authorize_url, code_verifier).""" + verifier = secrets.token_urlsafe(64) + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + .decode() + .rstrip("=") + ) + params = urllib.parse.urlencode( + { + "provider": "google", + "redirect_to": f"{app_url()}/api/auth/google/callback", + "code_challenge": challenge, + "code_challenge_method": "s256", + } + ) + return f"{_supabase_url()}/auth/v1/authorize?{params}", verifier + + +def exchange(code: str, verifier: str) -> dict: + """Exchange the PKCE code for the Supabase user. Returns {email, name}. + Raises ValueError with a user-safe message on any failure.""" + body = json.dumps({"auth_code": code, "code_verifier": verifier}).encode() + req = urllib.request.Request( + f"{_supabase_url()}/auth/v1/token?grant_type=pkce", + data=body, + headers={ + "apikey": os.environ["SUPABASE_ANON_KEY"], + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + payload = json.load(resp) + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc: + raise ValueError("Google sign-in could not be completed. Please try again.") from exc + + user = payload.get("user") or {} + email = (user.get("email") or "").strip().lower() + if not email: + raise ValueError("Google sign-in returned no email address.") + meta = user.get("user_metadata") or {} + name = (meta.get("full_name") or meta.get("name") or email.split("@")[0]).strip() + return {"email": email, "name": name} diff --git a/backend/app/ccr.py b/backend/app/ccr.py index 06cc7d40dc71ec8c08e370becd71ff9452e266ff..f3ab66d8ad792507392b4c0f68c982b52efe3246 100644 --- a/backend/app/ccr.py +++ b/backend/app/ccr.py @@ -1,4 +1,4 @@ -"""CCR engine — Contextualized Construct Representations. +"""CCR engine - Contextualized Construct Representations. Method (Atari, Omrani et al.): embed validated questionnaire items and the texts to be analyzed with a contextual sentence-embedding model, then take @@ -7,7 +7,7 @@ similarities are the text's "loadings" on the construct; their mean is the overall CCR score. The embedding model is injected behind a small interface so that: - * production uses sentence-transformers (local, pinned, reproducible — + * production uses sentence-transformers (local, pinned, reproducible - corpora never leave the machine), and * tests/CI use a deterministic hash-based embedder with no ML dependency. """ @@ -28,21 +28,6 @@ ProgressCb = Callable[[float], None] FAKE_MODEL_NAME = "fake-deterministic" -AVAILABLE_MODELS = [ - { - "name": "sentence-transformers/all-MiniLM-L6-v2", - "label": "all-MiniLM-L6-v2 (default — fast, CCR reference model)", - }, - { - "name": "sentence-transformers/all-mpnet-base-v2", - "label": "all-mpnet-base-v2 (higher quality, slower)", - }, - { - "name": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", - "label": "paraphrase-multilingual-MiniLM-L12-v2 (50+ languages)", - }, -] - class EmbeddingBackend(Protocol): name: str @@ -57,14 +42,16 @@ class SentenceTransformerBackend: _cache: dict[str, object] = {} - def __init__(self, model_name: str): + def __init__(self, model_name: str, revision: str | None = None): self.name = model_name + self.revision = revision def _model(self): if self.name not in self._cache: from sentence_transformers import SentenceTransformer # lazy: heavy import - self._cache[self.name] = SentenceTransformer(self.name) + kwargs = {"revision": self.revision} if self.revision else {} + self._cache[self.name] = SentenceTransformer(self.name, **kwargs) return self._cache[self.name] @property @@ -121,10 +108,14 @@ class HashEmbeddingBackend: return out -def get_backend(model_name: str) -> EmbeddingBackend: - if model_name == FAKE_MODEL_NAME or os.environ.get("CCR_FAKE_EMBEDDINGS") == "1": +def get_backend(model_id: str) -> EmbeddingBackend: + """Resolve a REGISTRY model id (or the test fake) to an embedding backend.""" + if model_id == FAKE_MODEL_NAME or os.environ.get("CCR_FAKE_EMBEDDINGS") == "1": return HashEmbeddingBackend() - return SentenceTransformerBackend(model_name) + from . import registry # local import: engine stays importable without yaml deps + + cfg = registry.get_model(model_id) + return SentenceTransformerBackend(cfg.provider_model_id, revision=cfg.pinned_revision) @dataclass @@ -132,10 +123,31 @@ class CCRResult: similarities: np.ndarray # (n_docs, n_items) scores: np.ndarray # (n_docs,) mean over items metadata: dict + doc_embeddings: np.ndarray | None = None # exposed so jobs.py can cache them + + +def encode_unique(backend: EmbeddingBackend, texts: list[str], + progress_cb: ProgressCb | None = None) -> np.ndarray: + """Encode only unique texts, then scatter back to full row order. + + Duplicate rows are common in social-media corpora; embeddings are + deterministic per text, so encoding each unique text once is a pure + speedup with bit-identical output. + """ + unique: dict[str, int] = {} + for t in texts: + if t not in unique: + unique[t] = len(unique) + if len(unique) == len(texts): + return backend.encode(texts, progress_cb=progress_cb) + unique_texts = list(unique.keys()) + unique_emb = backend.encode(unique_texts, progress_cb=progress_cb) + idx = np.fromiter((unique[t] for t in texts), dtype=np.int64, count=len(texts)) + return unique_emb[idx] # Item-set embeddings are tiny and constantly reused (same construct run -# against many corpora) — cache them per (model, exact item wording). +# against many corpora) - cache them per (model, exact item wording). _item_embedding_cache: dict[tuple[str, str], np.ndarray] = {} @@ -154,8 +166,16 @@ def run_ccr( items: list[str], backend: EmbeddingBackend, progress_cb: ProgressCb | None = None, + item_prefix: str = "", + text_prefix: str = "", + doc_embeddings: np.ndarray | None = None, ) -> CCRResult: - """Compute CCR loadings: cosine(text, item) for every text × item pair.""" + """Compute CCR loadings: cosine(text, item) for every text × item pair. + + Prefixes come from the model registry's usage_config - E5-family models require + "query: " on BOTH sides for symmetric similarity. Prefixed strings feed the + encoder only; raw wording is what gets hashed and exported. + """ if not texts: raise ValueError("Corpus contains no non-empty texts.") if not items: @@ -163,7 +183,8 @@ def run_ccr( started = datetime.now(timezone.utc) - item_emb, items_cached = encode_items_cached(backend, items) + items_for_encoding = [item_prefix + i for i in items] if item_prefix else items + item_emb, items_cached = encode_items_cached(backend, items_for_encoding) if progress_cb: progress_cb(0.02) @@ -171,7 +192,13 @@ def run_ccr( if progress_cb: progress_cb(0.02 + 0.93 * frac) - doc_emb = backend.encode(texts, progress_cb=doc_progress) + embeddings_from_cache = doc_embeddings is not None and len(doc_embeddings) == len(texts) + if embeddings_from_cache: + doc_emb = doc_embeddings # precomputed for this exact corpus+model+prefix (jobs.py cache) + doc_progress(1.0) + else: + texts_for_encoding = [text_prefix + t for t in texts] if text_prefix else texts + doc_emb = encode_unique(backend, texts_for_encoding, progress_cb=doc_progress) # Both matrices are L2-normalized -> cosine similarity is a dot product. sims = doc_emb @ item_emb.T @@ -186,9 +213,12 @@ def run_ccr( "embedding_dim": int(doc_emb.shape[1]), "model_max_seq_length": getattr(backend, "max_seq_length", None), "item_embeddings_from_cache": items_cached, + "doc_embeddings_from_cache": embeddings_from_cache, "n_texts": len(texts), "n_items": len(items), "items_sha256_16": items_hash, + "item_prefix": item_prefix, + "text_prefix": text_prefix, "similarity": "cosine", "score": "mean of per-item cosine similarities", "python": sys.version.split()[0], @@ -206,4 +236,4 @@ def run_ccr( if progress_cb: progress_cb(0.97) - return CCRResult(similarities=sims, scores=scores, metadata=metadata) + return CCRResult(similarities=sims, scores=scores, metadata=metadata, doc_embeddings=doc_emb) diff --git a/backend/app/construct_files.py b/backend/app/construct_files.py new file mode 100644 index 0000000000000000000000000000000000000000..ed65ea573224bb9b281201260e384d33c6fc6242 --- /dev/null +++ b/backend/app/construct_files.py @@ -0,0 +1,110 @@ +"""Parse a construct's items from an uploaded CSV/XLSX file. + +Design (Deva, 2026-07-11): parse -> preview -> confirm. The file is parsed +into items + reverse flags and returned for the researcher to REVIEW AND EDIT +before saving - never silently imported, because in CCR the item wording IS +the instrument. + +Accepted shapes (tolerant, reusing the corpus ingest loaders): + * an "item" / "items" / "text" / "statement" / "question" column (case- + insensitive), else a single-column file, else the longest-string column; + * optional reverse-scoring either as a column ("reverse", "reversed", + "reverse_scored", "rev", "r"; truthy = 1/true/yes/y/r) or as a trailing + "(R)" / "(rev)" / "(reversed)" marker in the item text (the lab's own + spreadsheet convention - packages/construct_library/import_from_xlsx.py); + * blank rows dropped, exact duplicates dropped with a warning. +""" + +from __future__ import annotations + +import re + +import pandas as pd + +from .ingest import IngestError, load_corpus + +ITEM_COLUMNS = ("item", "items", "text", "statement", "question", "item_text") +REVERSE_COLUMNS = ("reverse", "reversed", "reverse_scored", "reverse-scored", "rev", "r") +TRUTHY = {"1", "true", "yes", "y", "r", "reverse", "reversed"} +REVERSE_MARKER = re.compile(r"\s*\((r|rev|reversed)\)\s*$", re.IGNORECASE) +MAX_ITEMS = 200 + + +def parse_construct_file(path: str) -> dict: + """Return {items: [{text, reverse_scored}], warnings: [str], source_column: str}.""" + try: + df, _info = load_corpus(path) + except IngestError as exc: + raise ValueError(str(exc)) from exc + if df.empty: + raise ValueError("The file contains no rows.") + + lower = {str(c).strip().lower(): c for c in df.columns} + + item_col = next((lower[c] for c in ITEM_COLUMNS if c in lower), None) + if item_col is None: + if len(df.columns) == 1: + item_col = df.columns[0] + else: # longest average string wins - same heuristic family as corpora + def avg_len(col): + s = df[col].astype("string").dropna() + return s.str.len().mean() if len(s) else 0 + item_col = max(df.columns, key=avg_len) + + reverse_col = next((lower[c] for c in REVERSE_COLUMNS if c in lower), None) + if reverse_col == item_col: + reverse_col = None + + warnings: list[str] = [] + items: list[dict] = [] + seen: set[str] = set() + n_blank = n_dupes = 0 + + for _, row in df.iterrows(): + raw = row[item_col] + text = "" if pd.isna(raw) else str(raw).strip() + if not text: + n_blank += 1 + continue + + reverse = False + if reverse_col is not None: + flag = row[reverse_col] + if not pd.isna(flag): + s = str(flag).strip().lower() + try: # pandas floats an int column containing blanks: 1 -> "1.0" + reverse = float(s) != 0 + except ValueError: + reverse = s in TRUTHY + if REVERSE_MARKER.search(text): + reverse = True + text = REVERSE_MARKER.sub("", text).strip() + + if text in seen: + n_dupes += 1 + continue + seen.add(text) + items.append({"text": text, "reverse_scored": reverse}) + + if not items: + raise ValueError(f"No usable items found in column '{item_col}'.") + if len(items) > MAX_ITEMS: + raise ValueError( + f"{len(items)} items found; a construct is capped at {MAX_ITEMS}. " + "If this file holds multiple scales, split it per construct." + ) + + if n_blank: + warnings.append(f"{n_blank} blank row(s) skipped.") + if n_dupes: + warnings.append(f"{n_dupes} duplicate item(s) skipped.") + if reverse_col is None and not any(i["reverse_scored"] for i in items): + warnings.append( + "No reverse-scoring information found. Mark reverse-scored items by " + "appending (R) to the item text, or include a 'reverse' column." + ) + warnings.append( + "Review each item against the original publication before research use - " + "the item wording IS the instrument." + ) + return {"items": items, "warnings": warnings, "source_column": str(item_col)} diff --git a/backend/app/construct_lib.py b/backend/app/construct_lib.py new file mode 100644 index 0000000000000000000000000000000000000000..7db15f75d5aec990a12e12afe041b5a6fcf791f7 --- /dev/null +++ b/backend/app/construct_lib.py @@ -0,0 +1,121 @@ +"""Construct library loader - seeds the DB from packages/construct_library/constructs/. + +Source of truth is the versioned YAML files (spec 0004, design doc §10.1). Rules: + * append-only: (construct_id, version) is immutable - same version with changed + items is a hard error, never a silent update; + * item_hash uses the REFERENCE implementation from validate_constructs.py (loaded + by file path) so validator, seeder, and metadata always agree; + * verification_status flows to the UI - unverified wording is visibly flagged. + +New questionnaires from the lab land as new YAML files; `python packages/construct_library/ +validate_constructs.py` first, then restart the app (or call sync) to pick them up. +""" + +from __future__ import annotations + +import importlib.util +import json +import logging +from pathlib import Path + +import yaml +from sqlalchemy.orm import Session + +from .models import Construct + +logger = logging.getLogger("ccr.constructs") + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONSTRUCTS_DIR = REPO_ROOT / "packages" / "construct_library" / "constructs" +_VALIDATOR_PY = REPO_ROOT / "packages" / "construct_library" / "validate_constructs.py" + + +def _reference_item_hash(): + spec = importlib.util.spec_from_file_location("ccr_construct_validator", _VALIDATOR_PY) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.item_hash + + +def load_yaml_constructs() -> list[dict]: + files = sorted(CONSTRUCTS_DIR.glob("*.yaml")) + out = [] + for f in files: + data = yaml.safe_load(f.read_text()) + data["_file"] = f.name + out.append(data) + return out + + +def sync_library(db: Session) -> dict: + """Idempotent seed/update of library constructs. Returns a small report.""" + item_hash = _reference_item_hash() + report = {"inserted": 0, "unchanged": 0, "errors": []} + + for c in load_yaml_constructs(): + slug, version = c["construct_id"], int(c["version"]) + computed_hash = item_hash(c) + + existing = ( + db.query(Construct) + .filter_by(construct_slug=slug, version=version, is_seed=True) + .one_or_none() + ) + if existing: + if existing.item_hash != computed_hash: + # Append-only violation: same version, different wording. Refuse loudly. + report["errors"].append( + f"{c['_file']}: items changed under existing version {version} " + f"(hash {existing.item_hash[:12]} -> {computed_hash[:12]}). " + "Create a NEW version instead of editing this one." + ) + else: + report["unchanged"] += 1 + continue + + db.add( + Construct( + name=c["name"], + description=c.get("description", ""), + reference=c.get("citation", ""), + items_json=json.dumps([str(i["text"]) for i in c["items"]]), + reverse_flags_json=json.dumps([bool(i.get("reverse_scored", False)) for i in c["items"]]), + is_seed=True, + construct_slug=slug, + version=version, + item_hash=computed_hash, + verification_status=c.get("verification_status", "needs_verification"), + language=c.get("language", "en"), + category=c.get("category", ""), + ) + ) + report["inserted"] += 1 + + db.commit() + if report["errors"]: + for e in report["errors"]: + logger.error("construct library: %s", e) + raise RuntimeError( + "Construct library append-only violation(s): " + " | ".join(report["errors"]) + ) + logger.info("construct library sync: %s", report) + return report + + +def construct_snapshot(construct: Construct) -> dict: + """Immutable snapshot embedded in every run's metadata (design §10.1).""" + items = json.loads(construct.items_json) + flags = json.loads(construct.reverse_flags_json or "[]") or [False] * len(items) + return { + "construct_id": construct.construct_slug or f"custom_{construct.id[:8]}", + "version": construct.version or 1, + "name": construct.name, + "language": construct.language or "en", + "items": [ + {"text": t, "reverse_scored": bool(f)} for t, f in zip(items, flags) + ], + "item_hash": construct.item_hash or "", + "citation": construct.reference or "", + "verification_status": construct.verification_status or "draft", + "source_type": "predefined" if construct.is_seed else "user_custom", + } diff --git a/backend/app/db.py b/backend/app/db.py index 5fb00810ea294e2f1ef4ec9654dfc143d8adf68a..a4b2e7c46b059759be7165a8ff9046ea6c23f40f 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,4 +1,4 @@ -"""Database setup — SQLite via SQLAlchemy. +"""Database setup - SQLite via SQLAlchemy. SQLite is a deliberate choice for this deployment size (single-node, few concurrent writers). The models use no SQLite-specific features, so moving @@ -54,3 +54,47 @@ def get_db(): yield db finally: db.close() + + +def _sqlite_literal(value) -> str: + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (int, float)): + return str(value) + return "'" + str(value).replace("'", "''") + "'" + + +def auto_migrate_sqlite(target_engine, metadata) -> list[str]: + """Add ORM columns missing from existing SQLite tables (additive only). + + create_all() creates missing tables but never alters existing ones, so a + dev DB from last week 500s on this week's new column. This closes that gap + for the additive changes we make; anything non-additive (renames, drops, + type changes) waits for Alembic, which replaces this in Phase 2 alongside + Postgres. Columns with scalar defaults get that default; callable defaults + (uuid/now) are added nullable and filled by the ORM on new rows. + """ + import logging + + from sqlalchemy import inspect, text + + added: list[str] = [] + inspector = inspect(target_engine) + with target_engine.begin() as conn: + for table in metadata.sorted_tables: + if table.name not in inspector.get_table_names(): + continue # create_all handles brand-new tables + existing = {c["name"] for c in inspector.get_columns(table.name)} + for column in table.columns: + if column.name in existing: + continue + col_type = column.type.compile(target_engine.dialect) + ddl = f'ALTER TABLE {table.name} ADD COLUMN "{column.name}" {col_type}' + default = getattr(column.default, "arg", None) + if default is not None and not callable(default): + ddl += f" DEFAULT {_sqlite_literal(default)}" + conn.execute(text(ddl)) + added.append(f"{table.name}.{column.name}") + if added: + logging.getLogger("ccr.db").warning("auto-migrated columns: %s", ", ".join(added)) + return added diff --git a/backend/app/ingest.py b/backend/app/ingest.py index d38acc1ec56486b640bd1a131b3e49b001308135..bbd234e179216254e1fb587f8013d48ffdceeae3 100644 --- a/backend/app/ingest.py +++ b/backend/app/ingest.py @@ -1,4 +1,4 @@ -"""Corpus ingestion — tolerant of real-world research files. +"""Corpus ingestion - tolerant of real-world research files. Researchers upload CSVs exported from Qualtrics, Excel, R, SPSS, and scrapers: BOMs, latin-1 encodings, semicolon/tab delimiters, ragged rows. @@ -34,7 +34,7 @@ def load_corpus(path: str | Path) -> tuple[pd.DataFrame, dict]: """Parse CSV/XLSX into a DataFrame. Returns (df, parse_info) where parse_info records the format, - encoding, and delimiter actually used — stored with the corpus and + encoding, and delimiter actually used - stored with the corpus and echoed into every run's reproducibility metadata. """ p = Path(path) @@ -48,40 +48,62 @@ def load_corpus(path: str | Path) -> tuple[pd.DataFrame, dict]: last_error: Exception | None = None for encoding in _ENCODINGS: - # First attempt: delimiter sniffing (handles ',', ';', '\t', '|'). - # The python engine is slower but supports sniffing + bad-line skips; - # fine at lab scale. Falls back to plain comma parsing for files the - # sniffer chokes on (e.g., single-column CSVs). - for sep, sep_label in ((None, "sniffed"), (",", ",")): - try: - df = pd.read_csv( - p, - encoding=encoding, - sep=sep, - engine="python", - on_bad_lines="skip", + # Delimiter detection restricted to REAL delimiter candidates (, ; tab |). + # Unrestricted sniffing famously "detects" spaces in single-column files + # of natural-language sentences, exploding the header into word-columns. + try: + sep = _detect_delimiter(p, encoding) + df = pd.read_csv( + p, + encoding=encoding, + sep=sep, + engine="python", + on_bad_lines="skip", + ) + info = { + "format": "csv", + "encoding": encoding, + "delimiter": {"\t": "tab"}.get(sep, sep), + } + if encoding == "latin-1": + info["note"] = ( + "File was not valid UTF-8; decoded as latin-1. " + "Verify non-ASCII characters rendered correctly." ) - info = { - "format": "csv", - "encoding": encoding, - "delimiter": _describe_sep(df, sep_label), - } - if encoding == "latin-1": - info["note"] = ( - "File was not valid UTF-8; decoded as latin-1. " - "Verify non-ASCII characters rendered correctly." - ) - return _validate(df), info - except IngestError: - raise - except Exception as exc: # try next (sep, encoding) combination - last_error = exc + return _validate(df), info + except IngestError: + raise + except Exception as exc: # try next encoding + last_error = exc raise IngestError(f"Could not parse CSV file: {last_error}") -def _describe_sep(df: pd.DataFrame, sep_label: str) -> str: - return sep_label +_DELIMITER_CANDIDATES = (",", ";", "\t", "|") + + +def _detect_delimiter(p: Path, encoding: str) -> str: + """Pick the candidate delimiter most consistent across the first lines. + + Single-column files (no candidate present) default to ',' - a comma parse + of a delimiter-free file yields one column, which is exactly right. + """ + try: + with open(p, encoding=encoding, errors="strict") as fh: + lines = [line for line, _ in zip(fh, range(20)) if line.strip()] + except UnicodeDecodeError: + raise ValueError(f"not decodable as {encoding}") + if not lines: + return "," + + def score(delim: str) -> tuple[int, int]: + counts = [line.count(delim) for line in lines] + present = min(counts) > 0 + consistent = len(set(counts)) == 1 + return (int(present) + int(present and consistent), counts[0]) + + best = max(_DELIMITER_CANDIDATES, key=score) + return best if score(best)[0] > 0 else "," def _validate(df: pd.DataFrame) -> pd.DataFrame: @@ -89,7 +111,7 @@ def _validate(df: pd.DataFrame) -> pd.DataFrame: raise IngestError("The file parsed but contains no data rows.") if len(df) > max_rows(): raise IngestError( - f"File has {len(df):,} rows — above this instance's " + f"File has {len(df):,} rows - above this instance's " f"{max_rows():,}-row limit. Split the corpus or run locally." ) df.columns = [str(c) for c in df.columns] diff --git a/backend/app/jobs.py b/backend/app/jobs.py index eb11da3ac8753c8fe469d5a0ff6383288b455b55..bce959265e520560b9fe3c9caae60e993597e07f 100644 --- a/backend/app/jobs.py +++ b/backend/app/jobs.py @@ -1,6 +1,6 @@ """Background job runner. -Jobs run on a dedicated single-worker executor — a deliberate right-sizing: +Jobs run on a dedicated single-worker executor - a deliberate right-sizing: embedding is CPU-bound, so running jobs sequentially protects the instance's memory and keeps per-job throughput predictable, while job state lives in the DB (queued → running → completed/failed) so the API and UI never depend @@ -15,18 +15,28 @@ rather than hanging forever in the UI. from __future__ import annotations +import hashlib import json import logging +import os import traceback from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone import numpy as np -from .ccr import get_backend, run_ccr +from . import registry, warnings_engine +from .ccr import FAKE_MODEL_NAME, get_backend, run_ccr +from .construct_lib import construct_snapshot from .db import DATA_DIR, SessionLocal from .ingest import load_corpus -from .models import Construct, Corpus, Job +from .models import Construct, Corpus, Job, Project +from .reproducibility import record_environment +from .retention import EMB_CACHE_DIR, remove_corpus_files +from . import storage + +PLATFORM_VERSION = "0.2.0" +OUTPUT_SCHEMA_VERSION = "1.0" # bump on ANY export-column change (CLAUDE.md hard rule) logger = logging.getLogger("ccr.jobs") @@ -36,15 +46,32 @@ TOP_N = 10 SNIPPET_LEN = 220 # Single worker: sequential jobs, bounded memory. See module docstring. -_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ccr-job") +# Created lazily and re-creatable: a lifespan shutdown (dev reload, test +# client closing) must not permanently kill job submission for the process. +import threading + +_executor: ThreadPoolExecutor | None = None +_executor_lock = threading.Lock() + + +def _get_executor() -> ThreadPoolExecutor: + global _executor + with _executor_lock: + if _executor is None: + _executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ccr-job") + return _executor def submit_job(job_id: str) -> None: - _executor.submit(_run_job_logged, job_id) + _get_executor().submit(_run_job_logged, job_id) def shutdown_executor() -> None: - _executor.shutdown(wait=False, cancel_futures=True) + global _executor + with _executor_lock: + if _executor is not None: + _executor.shutdown(wait=False, cancel_futures=True) + _executor = None def recover_orphaned_jobs() -> int: @@ -99,7 +126,14 @@ def run_job(job_id: str) -> None: items = json.loads(construct.items_json) parse_info = json.loads(corpus.parse_info_json or "{}") - df, _ = load_corpus(corpus.path) + # Materialize the corpus locally (a no-op on the local backend; a + # temp download when files live in object storage). + local_corpus, corpus_is_temp = storage.fetch_to_local(corpus.path) + try: + df, _ = load_corpus(str(local_corpus)) + finally: + if corpus_is_temp: + local_corpus.unlink(missing_ok=True) if job.text_column not in df.columns: raise ValueError(f"Column '{job.text_column}' not found in corpus.") @@ -109,33 +143,109 @@ def run_job(job_id: str) -> None: work_df = df.loc[mask].reset_index(drop=True) texts = work_df[job.text_column].astype(str).tolist() + last_progress = -1.0 + def progress(frac: float): - _set(db, job, progress=round(float(frac), 3)) + # Throttled: commit only on >=1% movement (or completion) so large + # corpora don't turn the progress bar into a DB write hotspot. + nonlocal last_progress + frac = round(float(frac), 3) + if frac - last_progress >= 0.01 or frac >= 1.0: + last_progress = frac + _set(db, job, progress=frac) + + # Model config from the registry (spec 0003); the test fake has none. + model_cfg = None if job.model_name == FAKE_MODEL_NAME else registry.get_model(job.model_name) + item_prefix = model_cfg.item_prefix if (model_cfg and model_cfg.requires_prefix) else "" + text_prefix = model_cfg.text_prefix if (model_cfg and model_cfg.requires_prefix) else "" + + project = db.get(Project, job.project_id) + is_anonymous = not (project and project.owner_user_id) + + # Corpus-embedding cache: the CCR workflow is many constructs against + # the SAME corpus, and ~97% of a run is embedding the documents. Corpora + # are immutable after upload, so (corpus, column, model, revision, + # prefix) fully determines the embeddings - reusing them is bit-identical. + # Disabled for the test fake (unless forced) and skipped for anonymous + # runs (their files are removed right after the run anyway). + cache_enabled = os.environ.get("CCR_EMB_CACHE", "1") == "1" and ( + model_cfg is not None or os.environ.get("CCR_EMB_CACHE_FORCE") == "1" + ) + cache_path = None + cached_embeddings = None + if cache_enabled: + key = hashlib.sha256( + f"{job.text_column}|{job.model_name}|" + f"{model_cfg.revision if model_cfg else 'fake'}|{text_prefix}".encode() + ).hexdigest()[:20] + cache_path = EMB_CACHE_DIR / f"{corpus.id}_{key}.npy" + if cache_path.exists(): + try: + candidate = np.load(cache_path) + if candidate.shape[0] == len(texts): + cached_embeddings = candidate + except Exception: + cache_path.unlink(missing_ok=True) # unreadable cache: recompute backend = get_backend(job.model_name) - result = run_ccr(texts, items, backend, progress_cb=progress) - - # Data-quality warnings surfaced to the researcher, not buried in logs. - warnings = [] + result = run_ccr( + texts, items, backend, + progress_cb=progress, item_prefix=item_prefix, text_prefix=text_prefix, + doc_embeddings=cached_embeddings, + ) + if ( + cache_enabled and cache_path is not None and cached_embeddings is None + and not is_anonymous and result.doc_embeddings is not None + ): + try: + np.save(cache_path, result.doc_embeddings) + except Exception: + logger.warning("could not write embedding cache %s", cache_path) + + # Structured data-quality warnings (spec 0001) - objects, never bare strings. + W = warnings_engine.warning + warnings: list[dict] = [] if dropped: - warnings.append(f"{dropped} empty text row(s) were dropped before analysis.") + warnings.append(W( + "EMPTY_ROWS_DROPPED", "info", + f"{dropped} empty text row(s) were dropped before analysis.", count=dropped, + )) n_dupes = len(texts) - len(set(texts)) if n_dupes: - warnings.append( - f"{n_dupes} duplicate text(s) detected — each is scored " - "independently; deduplicate upstream if unintended." - ) - max_seq = result.metadata.get("model_max_seq_length") + warnings.append(W( + "DUPLICATE_TEXTS", "warning", + f"{n_dupes} duplicate text(s) detected - each is scored independently; " + "deduplicate upstream if unintended.", count=n_dupes, + )) + short = warnings_engine.short_text_warning(texts) + if short: + warnings.append(short) + max_seq = model_cfg.max_seq_length if model_cfg else result.metadata.get("model_max_seq_length") if max_seq: char_budget = int(max_seq) * 4 # rough chars-per-token heuristic n_long = sum(1 for t in texts if len(t) > char_budget) if n_long: - warnings.append( - f"{n_long} text(s) likely exceed the model's {max_seq}-token " - "window and were truncated; consider splitting long documents." - ) + warnings.append(W( + "TEXTS_MAYBE_TRUNCATED", "warning", + f"{n_long} text(s) likely exceed the model's {max_seq}-token window and " + "were truncated; consider splitting long documents.", count=n_long, + )) if parse_info.get("note"): - warnings.append(parse_info["note"]) + warnings.append(W("ENCODING_FALLBACK", "warning", parse_info["note"])) + + # Language checks: corpus-level detection + model-coverage (spec 0001, design §12). + selected_language = (job.language or "en").lower() + lang_result, lang_warnings = warnings_engine.detect_corpus_language(texts, selected_language) + warnings.extend(lang_warnings) + if model_cfg: + mlw = warnings_engine.model_language_warning( + selected_language, model_cfg.id, model_cfg.supported_languages, + model_cfg.language_set_name, + ) + if mlw: + warnings.append(mlw) + for user_warning in (model_cfg.user_warnings if model_cfg else ()): + warnings.append(W("MODEL_NOTE", "info", user_warning)) # Export mirrors ccr_wrapper's shape: input columns + per-item # similarity columns + overall score, so it drops into existing @@ -144,8 +254,9 @@ def run_job(job_id: str) -> None: for j in range(result.similarities.shape[1]): out[f"sim_item_{j + 1}"] = np.round(result.similarities[:, j], 6) out["ccr_score"] = np.round(result.scores, 6) - result_path = RESULTS_DIR / f"{job.id}.csv" - out.to_csv(result_path, index=False) + local_result = RESULTS_DIR / f"{job.id}.csv" + out.to_csv(local_result, index=False) + result_path = storage.move_local_into_storage("results", f"{job.id}.csv", local_result) scores = result.scores order = np.argsort(scores) @@ -181,14 +292,45 @@ def run_job(job_id: str) -> None: metadata = { **result.metadata, "job_id": job.id, + "platform_version": PLATFORM_VERSION, + "output_schema_version": OUTPUT_SCHEMA_VERSION, "corpus_file": corpus.filename, "corpus_parse_info": parse_info, "text_column": job.text_column, + "language": lang_result.as_metadata(), "construct": construct.name, "construct_reference": construct.reference, + "construct_snapshot": construct_snapshot(construct), + "model_registry_id": model_cfg.id if model_cfg else job.model_name, + "provider_model_id": model_cfg.provider_model_id if model_cfg else job.model_name, + "model_revision": model_cfg.revision if model_cfg else None, + "scoring": {"adjustment_strategy": "none", "aggregate": "mean_all_items"}, + "output_schema": ( + list(work_df.columns) + + [f"sim_item_{j + 1}" for j in range(result.similarities.shape[1])] + + ["ccr_score"] + ), + "warnings": warnings, "n_rows_input": int(corpus.n_rows), "n_rows_dropped_empty": dropped, } + record_environment(metadata) # pins exact package versions for the repro bundle + + # Retention (PI decision 2026-07-10): anonymous uploads are removed the + # moment analysis finishes. The results summary/CSV stay downloadable + # until the anonymous project's TTL purge; the raw upload does not. + if is_anonymous: + remove_corpus_files(corpus) + corpus.path = "" + metadata["anonymous_corpus_removed"] = True + warnings.append(W( + "ANONYMOUS_DATA_REMOVED", "info", + "The uploaded file was deleted after this analysis (anonymous runs " + "keep no raw data). Re-running requires uploading again, or sign in " + "to keep datasets.", + )) + summary["warnings"] = warnings + metadata["warnings"] = warnings _set( db, diff --git a/backend/app/main.py b/backend/app/main.py index 3af8c43b4d9cde8ea38c474046d1867139bbc8bc..f852d61d4b121df56ddcb16431f7813bf9841d0f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,4 @@ -"""CCR Platform — FastAPI application. +"""CCR Platform - FastAPI application. Single deployable: serves the JSON API under /api and the prebuilt React dashboard as static files at /. Local-first by design: corpora, embeddings, @@ -10,61 +10,81 @@ against pinned model weights. from __future__ import annotations import json +import os from contextlib import asynccontextmanager from pathlib import Path -from fastapi import Depends, FastAPI, HTTPException, UploadFile +from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session +from . import auth, auth_google, retention, storage from . import jobs as jobs_module -from .ccr import AVAILABLE_MODELS, FAKE_MODEL_NAME -from .db import DATA_DIR, Base, SessionLocal, engine, get_db +from . import registry +from .ccr import FAKE_MODEL_NAME +from .construct_files import parse_construct_file +from .construct_lib import sync_library +from .db import DATA_DIR, Base, SessionLocal, auto_migrate_sqlite, engine, get_db from .ingest import IngestError, load_corpus, suggest_text_column -from .models import Construct, Corpus, Job, Project +from .models import Construct, Corpus, Job, Project, User +from .reproducibility import requirements_text, script_text from .schemas import ( ConstructCreate, ConstructOut, CorpusOut, JobCreate, JobOut, + LoginIn, ProjectCreate, ProjectOut, + ProjectPatch, + RegisterIn, ) -from .seed_constructs import SEED_CONSTRUCTS MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # sane lab-scale ceiling; raise deliberately ALLOWED_SUFFIXES = (".csv", ".xlsx", ".xls") +# Languages offered in the UI selector; detection may report others (ISO 639-1). +SELECTABLE_LANGUAGES = [ + "en", "es", "fr", "de", "it", "pt", "nl", "ru", "zh", "ja", "ko", "ar", "hi", "tr", "fa", +] + @asynccontextmanager async def lifespan(_: FastAPI): - """Create tables and seed the construct library once at startup.""" + """Create tables and sync the construct library (YAML source of truth) at startup.""" Base.metadata.create_all(engine) + auto_migrate_sqlite(engine, Base.metadata) # additive column adds for existing dev DBs + registry.list_models() # fail fast on an invalid models.yaml db = SessionLocal() try: - if db.query(Construct).filter_by(is_seed=True).count() == 0: - for seed in SEED_CONSTRUCTS: - db.add( - Construct( - name=seed["name"], - description=seed["description"], - reference=seed["reference"], - items_json=json.dumps(seed["items"]), - is_seed=True, - ) - ) - db.commit() + sync_library(db) finally: db.close() jobs_module.recover_orphaned_jobs() + retention.start_cleanup() # anonymous-data TTL purge (no-op if CCR_ANON_TTL_HOURS=0) + if os.environ.get("CCR_WARM_MODEL") == "1" and os.environ.get("CCR_FAKE_EMBEDDINGS") != "1": + import threading + + def _warm(): + try: + from .ccr import get_backend + + get_backend(registry.default_model().id).encode(["warm up"]) + except Exception: + pass # first real run will load the model instead + + threading.Thread(target=_warm, daemon=True, name="ccr-warmup").start() yield + retention.stop_cleanup() jobs_module.shutdown_executor() app = FastAPI(title="CCR Platform", version="0.1.0", lifespan=lifespan) +app.add_middleware(GZipMiddleware, minimum_size=1024) # constructs payload + SPA compress ~4-5x app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], # Vite dev server @@ -75,13 +95,21 @@ app.add_middleware( # ---------------------------------------------------------------- helpers def _construct_out(c: Construct) -> ConstructOut: + items = json.loads(c.items_json) + flags = json.loads(c.reverse_flags_json or "[]") or [False] * len(items) return ConstructOut( id=c.id, name=c.name, description=c.description, reference=c.reference, - items=json.loads(c.items_json), + items=items, + reverse_scored=flags, is_seed=c.is_seed, + version=c.version or 1, + verification_status=c.verification_status or "draft", + language=c.language or "en", + category=c.category or "", + item_hash=(c.item_hash or "")[:16], ) @@ -97,6 +125,7 @@ def _job_out(db: Session, j: Job) -> JobOut: corpus_filename=corpus.filename if corpus else "", text_column=j.text_column, model_name=j.model_name, + language=j.language or "en", status=j.status, progress=j.progress, error=j.error, @@ -127,21 +156,279 @@ def health(): @app.get("/api/models") def list_models(): - return AVAILABLE_MODELS + """Model options from the registry (spec 0003) - never hardcoded.""" + return [ + { + "id": m.id, + "label": m.display_name, + "default": m.default, + "languages": (m.language_set_name or ", ".join(sorted(m.supported_languages)) or "unspecified"), + "speed_tier": m.speed_tier, + "quality_tier": m.quality_tier, + "warnings": list(m.user_warnings), + } + for m in registry.list_models() + ] + + +@app.get("/api/languages") +def list_languages(): + return SELECTABLE_LANGUAGES + + +# ------------------------------------------------------------------ accounts +# Local email+password accounts (auth.py) - the free interim provider. The +# managed swap (Supabase: Google + email/password) replaces token issuance +# only; every other endpoint just depends on auth.get_current_user. +def _saved_runs_used(db: Session, user_id: str) -> int: + return ( + db.query(Job) + .join(Project, Job.project_id == Project.id) + .filter(Project.owner_user_id == user_id, Job.status.in_(("queued", "running", "completed"))) + .count() + ) + + +def _set_session_cookie(response: Response, user: User) -> None: + response.set_cookie( + auth.COOKIE_NAME, + auth.create_session_token(user.id, user.email, user.name), + httponly=True, + samesite="lax", + secure=auth.cookies_secure(), + max_age=30 * 24 * 3600, + ) + + +@app.get("/api/auth/me") +def auth_me( + request: Request, + db: Session = Depends(get_db), + user: dict | None = Depends(auth.get_current_user), +): + if user: + return { + "signed_in": True, + "name": user["name"], + "email": user["email"], + "limits": {"max_bytes": MAX_UPLOAD_BYTES, "max_rows": None}, + "usage": { + "saved_runs": _saved_runs_used(db, user["id"]), + "max_saved_runs": auth.user_max_saved_runs(), + }, + } + return { + "signed_in": False, + "name": None, + "email": None, + "google_available": auth_google.configured(), + "limits": {"max_bytes": auth.anon_max_bytes(), "max_rows": auth.anon_max_rows()}, + "usage": { + "runs_used_today": auth.runs_used_today(request), + "max_runs_per_day": auth.anon_max_runs_per_day(), + }, + } + + +@app.post("/api/auth/register", status_code=201) +def register(body: RegisterIn, response: Response, db: Session = Depends(get_db)): + email = body.email.strip().lower() + if not auth.valid_email(email): + raise HTTPException(400, "Please enter a valid email address.") + if len(body.password) < auth.MIN_PASSWORD_LEN: + raise HTTPException(400, f"Password must be at least {auth.MIN_PASSWORD_LEN} characters.") + if db.query(User).filter_by(email=email).first(): + raise HTTPException(409, "An account with this email already exists. Sign in instead.") + user = User(email=email, name=body.name.strip(), password_hash=auth.hash_password(body.password)) + db.add(user) + db.commit() + _set_session_cookie(response, user) + return {"signed_in": True, "name": user.name, "email": user.email} + + +@app.post("/api/auth/login") +def login(body: LoginIn, response: Response, db: Session = Depends(get_db)): + email = body.email.strip().lower() + user = db.query(User).filter_by(email=email).first() + if user is not None and not user.password_hash: + raise HTTPException(401, "This account uses Google sign-in - use the Google button.") + if user is None or not auth.verify_password(body.password, user.password_hash): + raise HTTPException(401, "Incorrect email or password.") + _set_session_cookie(response, user) + return {"signed_in": True, "name": user.name, "email": user.email} + + +@app.get("/api/auth/google/login") +def google_login(): + """Start the Google sign-in flow (Supabase PKCE). Plain redirect - the + frontend links here directly, no SDK involved.""" + if not auth_google.configured(): + raise HTTPException(503, "Google sign-in is not configured on this instance.") + from fastapi.responses import RedirectResponse + + url, verifier = auth_google.begin() + resp = RedirectResponse(url, status_code=307) + resp.set_cookie( + auth_google.VERIFIER_COOKIE, + auth.sign_payload({"v": verifier}), + httponly=True, + samesite="lax", + secure=auth.cookies_secure(), + max_age=auth_google.VERIFIER_TTL_SECONDS, + ) + return resp + + +@app.get("/api/auth/google/callback") +def google_callback(request: Request, code: str = "", db: Session = Depends(get_db)): + from fastapi.responses import RedirectResponse + + def fail(msg: str): + return RedirectResponse(f"/?auth_error={msg}", status_code=307) + + if not auth_google.configured(): + return fail("google-not-configured") + payload = auth.verify_payload(request.cookies.get(auth_google.VERIFIER_COOKIE)) + if not code or not payload or "v" not in payload: + return fail("sign-in-expired-try-again") + try: + info = auth_google.exchange(code, payload["v"]) + except ValueError: + return fail("google-exchange-failed") + + user = db.query(User).filter_by(email=info["email"]).first() + if user is None: + # Google-verified account: no local password (password login is refused + # with a pointer to the Google button). + user = User(email=info["email"], name=info["name"], password_hash="") + db.add(user) + db.commit() + + resp = RedirectResponse("/", status_code=307) + resp.delete_cookie(auth_google.VERIFIER_COOKIE) + _set_session_cookie(resp, user) + return resp + + +@app.post("/api/auth/logout") +def logout(response: Response): + response.delete_cookie(auth.COOKIE_NAME) + return {"signed_in": False} # --------------------------------------------------------------- projects +def _visible_owners(user: dict | None) -> tuple[str, ...]: + """Anonymous viewers see anonymous projects; signed-in users additionally + see their own. Other users' projects are invisible (and untouchable).""" + return ("",) if user is None else ("", user["id"]) + + +def _require_project_access(project: Project, user: dict | None) -> None: + if project.owner_user_id and (user is None or project.owner_user_id != user["id"]): + raise HTTPException(403, "This project belongs to another account.") + + @app.get("/api/projects", response_model=list[ProjectOut]) -def list_projects(db: Session = Depends(get_db)): - return db.query(Project).order_by(Project.created_at.desc()).all() +def list_projects(db: Session = Depends(get_db), user: dict | None = Depends(auth.get_current_user)): + """Projects ordered by last activity (latest run, else creation) - the + project a researcher wants is almost always the one they last worked on.""" + from sqlalchemy import func + + activity = { + pid: (last, count) + for pid, last, count in db.query( + Job.project_id, func.max(Job.created_at), func.count(Job.id) + ) + .group_by(Job.project_id) + .all() + } + rows = db.query(Project).filter(Project.owner_user_id.in_(_visible_owners(user))).all() + out = [] + for p in rows: + last, count = activity.get(p.id, (None, 0)) + out.append( + ProjectOut( + id=p.id, + name=p.name, + description=p.description, + created_at=p.created_at, + last_activity_at=last or p.created_at, + n_runs=count, + archived=bool(p.archived), + ) + ) + out.sort(key=lambda x: x.last_activity_at, reverse=True) + return out @app.post("/api/projects", response_model=ProjectOut, status_code=201) -def create_project(body: ProjectCreate, db: Session = Depends(get_db)): - project = Project(name=body.name.strip(), description=body.description.strip()) +def create_project( + body: ProjectCreate, + db: Session = Depends(get_db), + user: dict | None = Depends(auth.get_current_user), +): + project = Project( + name=body.name.strip(), + description=body.description.strip(), + owner_user_id=user["id"] if user else "", # "" = anonymous (TTL purge applies) + ) db.add(project) db.commit() - return project + return ProjectOut( + id=project.id, + name=project.name, + description=project.description, + created_at=project.created_at, + last_activity_at=project.created_at, + n_runs=0, + archived=False, + ) + + +@app.patch("/api/projects/{project_id}", response_model=ProjectOut) +def patch_project( + project_id: str, + body: ProjectPatch, + db: Session = Depends(get_db), + user: dict | None = Depends(auth.get_current_user), +): + """Archive/unarchive - reversible, no data loss. Archived projects collapse + into the sidebar's Archived section and keep all datasets and runs.""" + project = _get_or_404(db, Project, project_id) + _require_project_access(project, user) + if body.archived is not None: + project.archived = bool(body.archived) + db.commit() + return ProjectOut( + id=project.id, + name=project.name, + description=project.description, + created_at=project.created_at, + last_activity_at=project.created_at, + n_runs=0, + archived=bool(project.archived), + ) + + +@app.delete("/api/projects/{project_id}", status_code=204) +def delete_project( + project_id: str, + db: Session = Depends(get_db), + user: dict | None = Depends(auth.get_current_user), +): + """Permanent delete: removes the project, its datasets, runs, uploaded + files, result files, and cached embeddings. Logged without retaining any + uploaded text (design doc §9).""" + import logging + + project = _get_or_404(db, Project, project_id) + _require_project_access(project, user) + counts = retention.delete_project_cascade(db, project) + logging.getLogger("ccr.projects").info( + "project deleted: id=%s name=%r corpora=%d runs=%d", + project_id, project.name, counts["corpora"], counts["runs"], + ) + return Response(status_code=204) # ----------------------------------------------------------------- corpora @@ -170,8 +457,14 @@ def list_corpora(project_id: str, db: Session = Depends(get_db)): @app.post("/api/projects/{project_id}/corpora", response_model=CorpusOut, status_code=201) -async def upload_corpus(project_id: str, file: UploadFile, db: Session = Depends(get_db)): - _get_or_404(db, Project, project_id) +async def upload_corpus( + project_id: str, + file: UploadFile, + db: Session = Depends(get_db), + user: dict | None = Depends(auth.get_current_user), +): + project = _get_or_404(db, Project, project_id) + _require_project_access(project, user) suffix = Path(file.filename or "upload.csv").suffix.lower() if suffix not in ALLOWED_SUFFIXES: @@ -181,19 +474,41 @@ async def upload_corpus(project_id: str, file: UploadFile, db: Session = Depends if len(payload) > MAX_UPLOAD_BYTES: raise HTTPException(413, "File exceeds the 25 MB upload limit.") + # Tier gate (design §5.1): anonymous users get strict caps; signing in + # lifts them. + if user is None and len(payload) > auth.anon_max_bytes(): + mb = auth.anon_max_bytes() // (1024 * 1024) + raise HTTPException( + 413, + f"Anonymous uploads are limited to {mb} MB. Sign in (top right) to upload larger files.", + ) + corpus = Corpus( project_id=project_id, filename=file.filename, path="", n_rows=0, columns_json="[]" ) - dest = DATA_DIR / "corpora" / f"{corpus.id}{suffix}" - dest.write_bytes(payload) - corpus.path = str(dest) + # Parse from a local temp file, then hand the bytes to the storage backend + # (local disk by default; S3/R2 when CCR_STORAGE=s3 in production). + tmp_dir = DATA_DIR / "tmp" + tmp_dir.mkdir(exist_ok=True) + tmp = tmp_dir / f"{corpus.id}{suffix}" + tmp.write_bytes(payload) try: - df, parse_info = load_corpus(str(dest)) + df, parse_info = load_corpus(str(tmp)) except IngestError as exc: - dest.unlink(missing_ok=True) + tmp.unlink(missing_ok=True) raise HTTPException(400, str(exc)) from exc + if user is None and len(df) > auth.anon_max_rows(): + tmp.unlink(missing_ok=True) + raise HTTPException( + 400, + f"Anonymous uploads are limited to {auth.anon_max_rows():,} rows " + f"(this file has {len(df):,}). Sign in (top right) to upload larger corpora.", + ) + + corpus.path = storage.move_local_into_storage("corpora", f"{corpus.id}{suffix}", tmp) + corpus.n_rows = int(len(df)) corpus.columns_json = json.dumps(list(df.columns)) corpus.parse_info_json = json.dumps(parse_info) @@ -227,30 +542,101 @@ def create_construct(body: ConstructCreate, db: Session = Depends(get_db)): items = [i.strip() for i in body.items if i.strip()] if not items: raise HTTPException(400, "Construct needs at least one non-empty item.") + flags = body.reverse_scored or [False] * len(items) + if len(flags) != len(items): + raise HTTPException(400, "reverse_scored must have one flag per item.") construct = Construct( name=body.name.strip(), description=body.description.strip(), reference=body.reference.strip(), items_json=json.dumps(items), + reverse_flags_json=json.dumps([bool(f) for f in flags]), is_seed=False, + verification_status="draft", # user-defined research tools, not validated scales + language=(body.language or "en").lower(), ) db.add(construct) db.commit() return _construct_out(construct) +@app.post("/api/constructs/parse-file") +async def parse_construct_upload(file: UploadFile): + """Parse a CSV/XLSX of scale items into a PREVIEW (nothing is saved). + The researcher reviews/edits, then saves via POST /api/constructs.""" + suffix = Path(file.filename or "items.csv").suffix.lower() + if suffix not in ALLOWED_SUFFIXES: + raise HTTPException(400, f"Unsupported file type '{suffix}'. Use CSV or XLSX.") + payload = await file.read() + if len(payload) > 1024 * 1024: + raise HTTPException(413, "Item files are capped at 1 MB (a scale is a short list).") + + tmp_dir = DATA_DIR / "tmp" + tmp_dir.mkdir(exist_ok=True) + tmp = tmp_dir / f"construct_upload_{os.urandom(6).hex()}{suffix}" + tmp.write_bytes(payload) + try: + parsed = parse_construct_file(str(tmp)) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + finally: + tmp.unlink(missing_ok=True) # item files are never retained + + stem = Path(file.filename or "").stem.replace("_", " ").replace("-", " ").strip() + parsed["suggested_name"] = stem.title() if stem else "" + return parsed + + # -------------------------------------------------------------------- jobs @app.post("/api/jobs", response_model=JobOut, status_code=201) -def create_job(body: JobCreate, db: Session = Depends(get_db)): - _get_or_404(db, Project, body.project_id) +def create_job( + body: JobCreate, + request: Request, + response: Response, + db: Session = Depends(get_db), + user: dict | None = Depends(auth.get_current_user), +): + project = _get_or_404(db, Project, body.project_id) + _require_project_access(project, user) corpus = _get_or_404(db, Corpus, body.corpus_id) _get_or_404(db, Construct, body.construct_id) + # Anonymous tier: N runs per day, then sign-in (PI decision 2026-07-10). + # Cookie counter = a nudge, not a security boundary (recorded in DECISIONS.md). + if user is None: + used = auth.runs_used_today(request) + if used >= auth.anon_max_runs_per_day(): + raise HTTPException( + 429, + f"Anonymous limit reached ({auth.anon_max_runs_per_day()} runs/day). " + "Sign in (top right) to keep running - accounts are free.", + ) + else: + # Signed-in tier: saved-run cap instead of deletion (their data, their call). + if _saved_runs_used(db, user["id"]) >= auth.user_max_saved_runs(): + raise HTTPException( + 409, + f"You have {auth.user_max_saved_runs()} saved runs (the maximum). " + "Delete a project or old runs to start a new analysis.", + ) + if body.text_column not in json.loads(corpus.columns_json): raise HTTPException(400, f"Column '{body.text_column}' not in corpus columns.") - allowed = {m["name"] for m in AVAILABLE_MODELS} | {FAKE_MODEL_NAME} + allowed = registry.known_ids() | {FAKE_MODEL_NAME} if body.model_name not in allowed: raise HTTPException(400, f"Unknown model '{body.model_name}'.") + language = (body.language or "en").strip().lower() + if not (2 <= len(language) <= 8 and language.replace("-", "").isalpha()): + raise HTTPException(400, f"Invalid language code '{body.language}'.") + + # Retention: anonymous uploads are deleted after their analysis, so a + # re-run needs a fresh upload (or an account, where data persists). + if not corpus.path or not storage.exists(corpus.path): + raise HTTPException( + 410, + "This dataset's file was removed after analysis (anonymous uploads are " + "not kept). Upload the file again, or sign in to keep datasets.", + ) job = Job( project_id=body.project_id, @@ -258,10 +644,21 @@ def create_job(body: JobCreate, db: Session = Depends(get_db)): construct_id=body.construct_id, text_column=body.text_column, model_name=body.model_name, + language=language, ) db.add(job) db.commit() jobs_module.submit_job(job.id) + + if user is None: # advance the daily counter only after the job is accepted + response.set_cookie( + auth.RUNS_COOKIE_NAME, + auth.run_counter_token(auth.runs_used_today(request) + 1), + httponly=True, + samesite="lax", + secure=auth.cookies_secure(), + max_age=24 * 3600, + ) return _job_out(db, job) @@ -294,9 +691,16 @@ def export_results(job_id: str, db: Session = Depends(get_db)): job = _get_or_404(db, Job, job_id) if job.status != "completed" or not job.result_path: raise HTTPException(409, "Results not available.") - return FileResponse( - job.result_path, media_type="text/csv", filename=f"ccr_results_{job_id[:8]}.csv" - ) + filename = f"ccr_results_{job_id[:8]}.csv" + if storage.is_s3(job.result_path): + from fastapi.responses import StreamingResponse + + return StreamingResponse( + storage.open_stream(job.result_path), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + return FileResponse(job.result_path, media_type="text/csv", filename=filename) @app.get("/api/jobs/{job_id}/metadata") @@ -312,6 +716,35 @@ def export_metadata(job_id: str, db: Session = Depends(get_db)): ) +@app.get("/api/jobs/{job_id}/script") +def export_script(job_id: str, db: Session = Depends(get_db)): + """Offline-runnable reproduction script generated from run metadata (spec 0002).""" + job = _get_or_404(db, Job, job_id) + if job.status != "completed": + raise HTTPException(409, "Script not available until the run completes.") + return PlainTextResponse( + script_text(json.loads(job.metadata_json)), + media_type="text/x-python", + headers={ + "Content-Disposition": f'attachment; filename="reproduce_analysis_{job_id[:8]}.py"' + }, + ) + + +@app.get("/api/jobs/{job_id}/script-requirements") +def export_script_requirements(job_id: str, db: Session = Depends(get_db)): + job = _get_or_404(db, Job, job_id) + if job.status != "completed": + raise HTTPException(409, "Requirements not available until the run completes.") + return PlainTextResponse( + requirements_text(json.loads(job.metadata_json)), + media_type="text/plain", + headers={ + "Content-Disposition": f'attachment; filename="requirements-repro_{job_id[:8]}.txt"' + }, + ) + + # ------------------------------------------------------------ static (SPA) STATIC_DIR = Path(__file__).resolve().parent.parent / "static" if STATIC_DIR.exists(): diff --git a/backend/app/models.py b/backend/app/models.py index 260c6e1b5311b83eafafb68431f27dfbcbfb3088..29e8ad1add3ea4100ccd0dfdeae2a18841aefb8c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -2,7 +2,7 @@ IDs are UUID strings (portable across SQLite/Postgres). JSON-ish payloads (column lists, construct items, job metadata/summaries) are stored as JSON -text — they are read-mostly blobs, not queried relationally. +text - they are read-mostly blobs, not queried relationally. """ import uuid @@ -22,12 +22,24 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + name: Mapped[str] = mapped_column(String(120), default="") + password_hash: Mapped[str] = mapped_column(Text) # scrypt$salt$digest (auth.py) + created_at: Mapped[str] = mapped_column(String(32), default=_now) + + class Project(Base): __tablename__ = "projects" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) name: Mapped[str] = mapped_column(String(200)) description: Mapped[str] = mapped_column(Text, default="") + archived: Mapped[bool] = mapped_column(Boolean, default=False) + owner_user_id: Mapped[str] = mapped_column(String(32), default="") # "" = anonymous created_at: Mapped[str] = mapped_column(String(32), default=_now) @@ -51,9 +63,18 @@ class Construct(Base): id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) name: Mapped[str] = mapped_column(String(200)) description: Mapped[str] = mapped_column(Text, default="") - reference: Mapped[str] = mapped_column(Text, default="") + reference: Mapped[str] = mapped_column(Text, default="") # citation items_json: Mapped[str] = mapped_column(Text) # list[str] + reverse_flags_json: Mapped[str] = mapped_column(Text, default="[]") # list[bool], parallel to items is_seed: Mapped[bool] = mapped_column(Boolean, default=False) + # Library identity (spec 0004): versioned append-only; hash via reference algorithm. + construct_slug: Mapped[str] = mapped_column(String(120), default="") + version: Mapped[int] = mapped_column(default=1) + item_hash: Mapped[str] = mapped_column(String(64), default="") + verification_status: Mapped[str] = mapped_column(String(24), default="draft") + # draft | needs_verification | verified | archived + language: Mapped[str] = mapped_column(String(12), default="en") + category: Mapped[str] = mapped_column(String(80), default="") created_at: Mapped[str] = mapped_column(String(32), default=_now) @@ -65,7 +86,8 @@ class Job(Base): corpus_id: Mapped[str] = mapped_column(ForeignKey("corpora.id")) construct_id: Mapped[str] = mapped_column(ForeignKey("constructs.id")) text_column: Mapped[str] = mapped_column(String(200)) - model_name: Mapped[str] = mapped_column(String(200)) + model_name: Mapped[str] = mapped_column(String(200)) # registry id (or test fake) + language: Mapped[str] = mapped_column(String(12), default="en") # selected analysis language status: Mapped[str] = mapped_column(String(20), default="queued") # queued -> running -> completed | failed progress: Mapped[float] = mapped_column(Float, default=0.0) # 0..1 diff --git a/backend/app/registry.py b/backend/app/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..ed7d089c5ffbb45ac1c75c5ac8070f7659c80353 --- /dev/null +++ b/backend/app/registry.py @@ -0,0 +1,134 @@ +"""Model registry loader - the app-side reader of packages/model_registry/models.yaml. + +Single source of model truth (design doc §13): the UI dropdown, backend validation, +prefix handling, language-support warnings, run metadata, and generated reproduction +scripts all read the SAME config through this module. No model behavior is hardcoded +anywhere else (CLAUDE.md hard rule). + +Language sets are resolved through packages/model_registry/language_sets.py, loaded +by explicit file path (editable-install wiring arrives with Phase 1 packaging). +""" + +from __future__ import annotations + +import importlib.util +import os +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODELS_YAML = Path(os.environ.get("CCR_MODELS_YAML", REPO_ROOT / "packages" / "model_registry" / "models.yaml")) +_LANGUAGE_SETS_PY = REPO_ROOT / "packages" / "model_registry" / "language_sets.py" + + +def _load_language_sets(): + spec = importlib.util.spec_from_file_location("ccr_language_sets", _LANGUAGE_SETS_PY) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@dataclass(frozen=True) +class ModelConfig: + id: str + provider_model_id: str + display_name: str + revision: str + default: bool + supported_languages: frozenset[str] # resolved ISO codes; frozenset() = unknown/any + language_set_name: str | None + embedding_dimension: int + max_seq_length: int + quality_tier: str + speed_tier: str + requires_prefix: bool + item_prefix: str + text_prefix: str + normalize_embeddings: bool + lazy_load: bool + user_warnings: tuple[str, ...] = field(default_factory=tuple) + + @property + def pinned_revision(self) -> str | None: + """Revision to pass to the model loader; None while unpinned (PIN_ME).""" + return None if self.revision in ("PIN_ME", "", None) else self.revision + + def supports_language(self, iso_code: str) -> bool: + if not self.supported_languages: # unknown coverage - never block/warn on it + return True + return iso_code.lower() in self.supported_languages + + +def _parse_model(raw: dict, lang_sets) -> ModelConfig: + usage = raw.get("usage_config", {}) + ops = raw.get("operational_config", {}) + set_name = raw.get("supported_language_set") + if set_name: + languages = frozenset(lang_sets.resolve(set_name)) + else: + languages = frozenset(str(c).lower() for c in raw.get("supported_languages", [])) + + sym = usage.get("symmetric_similarity_prefix") or "" + return ModelConfig( + id=raw["id"], + provider_model_id=raw["provider_model_id"], + display_name=raw.get("display_name", raw["id"]), + revision=str(raw.get("revision", "PIN_ME")), + default=bool(raw.get("default", False)), + supported_languages=languages, + language_set_name=set_name, + embedding_dimension=int(raw["embedding_dimension"]), + max_seq_length=int(raw["max_seq_length"]), + quality_tier=str(raw.get("quality_tier", "unknown")), + speed_tier=str(raw.get("speed_tier", "unknown")), + requires_prefix=bool(usage.get("requires_prefix", False)), + item_prefix=usage.get("construct_prefix") or sym or "", + text_prefix=usage.get("text_prefix") or sym or "", + normalize_embeddings=bool(usage.get("normalize_embeddings", True)), + lazy_load=bool(ops.get("lazy_load", False)), + user_warnings=tuple(raw.get("warnings", []) or []), + ) + + +@lru_cache(maxsize=1) +def _registry() -> dict[str, ModelConfig]: + data = yaml.safe_load(MODELS_YAML.read_text()) + lang_sets = _load_language_sets() + models = {} + for raw in data.get("models", []): + cfg = _parse_model(raw, lang_sets) + if cfg.requires_prefix and not (cfg.item_prefix and cfg.text_prefix): + raise ValueError(f"models.yaml: {cfg.id} requires_prefix but prefixes missing.") + models[cfg.id] = cfg + defaults = [m for m in models.values() if m.default] + if len(defaults) != 1: + raise ValueError(f"models.yaml must define exactly one default model, found {len(defaults)}.") + return models + + +def reload() -> None: + """Clear the cache (tests / config changes).""" + _registry.cache_clear() + + +def list_models() -> list[ModelConfig]: + ordered = sorted(_registry().values(), key=lambda m: (not m.default, m.id)) + return ordered + + +def get_model(model_id: str) -> ModelConfig: + reg = _registry() + if model_id not in reg: + raise KeyError(f"Unknown model id '{model_id}'. Known: {sorted(reg)}") + return reg[model_id] + + +def default_model() -> ModelConfig: + return next(m for m in _registry().values() if m.default) + + +def known_ids() -> set[str]: + return set(_registry().keys()) diff --git a/backend/app/reproducibility.py b/backend/app/reproducibility.py new file mode 100644 index 0000000000000000000000000000000000000000..c428e264ab0362911319e53039328beecbc35575 --- /dev/null +++ b/backend/app/reproducibility.py @@ -0,0 +1,118 @@ +"""Reproduction-script generation (spec 0002, design doc §14). + +The generated script is built ONLY from the run's stored metadata - never from live +state - so it reproduces what actually ran. It must be runnable outside the platform: +input CSV + Python + internet for the (pinned) model download. No platform credentials. +""" + +from __future__ import annotations + +import json + + +def _pinned_requirements(metadata: dict) -> list[str]: + """Exact versions of the packages the analysis math depends on.""" + from importlib.metadata import PackageNotFoundError, version + + pins = [] + for pkg in ("sentence-transformers", "torch", "numpy", "pandas"): + try: + pins.append(f"{pkg}=={version(pkg)}") + except PackageNotFoundError: + continue + return pins + + +def requirements_text(metadata: dict) -> str: + lines = [ + "# Reproduction environment for CCR run " + metadata.get("job_id", "?"), + "# Install: pip install -r requirements-repro.txt", + ] + recorded = metadata.get("environment_pins") + lines += recorded if recorded else _pinned_requirements(metadata) + return "\n".join(lines) + "\n" + + +def record_environment(metadata: dict) -> dict: + """Store pins in metadata at run time so exports match the executing environment.""" + metadata["environment_pins"] = _pinned_requirements(metadata) + return metadata + + +def script_text(metadata: dict) -> str: + """Standalone Python script reproducing the run's similarities and scores.""" + construct = metadata.get("construct_snapshot", {}) + items = construct.get("items", []) + model_id = metadata.get("model_registry_id", metadata.get("model", "")) + provider = metadata.get("provider_model_id", metadata.get("model", "")) + revision = metadata.get("model_revision") + revision_arg = f", revision={revision!r}" if revision and revision != "PIN_ME" else "" + item_prefix = metadata.get("item_prefix", "") + text_prefix = metadata.get("text_prefix", "") + text_column = metadata.get("text_column", "text") + scoring = metadata.get("scoring", {}) + + items_literal = json.dumps( + [{"text": i["text"], "reverse_scored": i.get("reverse_scored", False)} for i in items], + indent=4, ensure_ascii=False, + ) + + return f'''#!/usr/bin/env python3 +"""Reproduce CCR analysis independently of the platform. + +run_id: {metadata.get("job_id", "?")} +created_at: {metadata.get("started_at", "?")} +platform_version: {metadata.get("platform_version", "?")} +output_schema_version: {metadata.get("output_schema_version", "1.0")} +construct: {construct.get("name", "?")} (v{construct.get("version", "?")}, hash {construct.get("item_hash", "?")[:16]}) +model: {model_id} -> {provider} (revision: {revision or "unpinned"}) +scoring: adjustment_strategy={scoring.get("adjustment_strategy", "none")}, aggregate={scoring.get("aggregate", "mean_all_items")} + +Usage: + pip install -r requirements-repro.txt + python reproduce_analysis.py your_corpus.csv +Outputs reproduced_results.csv with the same similarity columns as the platform export. +""" + +import sys + +import numpy as np +import pandas as pd +from sentence_transformers import SentenceTransformer + +TEXT_COLUMN = {text_column!r} +ITEM_PREFIX = {item_prefix!r} # model-required prefix (E5 family); empty = none +TEXT_PREFIX = {text_prefix!r} + +ITEMS = {items_literal} + + +def main(csv_path: str) -> None: + df = pd.read_csv(csv_path) + texts_all = df[TEXT_COLUMN].astype("string") + mask = texts_all.notna() & (texts_all.str.strip() != "") + work = df.loc[mask].reset_index(drop=True) # platform drops empty rows the same way + texts = work[TEXT_COLUMN].astype(str).tolist() + + model = SentenceTransformer({provider!r}{revision_arg}) + item_texts = [ITEM_PREFIX + i["text"] for i in ITEMS] + doc_texts = [TEXT_PREFIX + t for t in texts] + + item_emb = model.encode(item_texts, convert_to_numpy=True, normalize_embeddings=True) + doc_emb = model.encode(doc_texts, convert_to_numpy=True, normalize_embeddings=True) + + sims = doc_emb @ item_emb.T # normalized -> cosine similarity + for j in range(sims.shape[1]): + work[f"sim_item_{{j + 1}}"] = np.round(sims[:, j], 6) + work["ccr_score"] = np.round(sims.mean(axis=1), 6) + + work.to_csv("reproduced_results.csv", index=False) + print(f"Wrote reproduced_results.csv ({{len(work)}} rows, {{sims.shape[1]}} items).") + print("Compare against the platform export - values should match to ~1e-5.") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("Usage: python reproduce_analysis.py ") + main(sys.argv[1]) +''' diff --git a/backend/app/retention.py b/backend/app/retention.py new file mode 100644 index 0000000000000000000000000000000000000000..f09c69dc242318c7895548fe3bb67907288743a2 --- /dev/null +++ b/backend/app/retention.py @@ -0,0 +1,123 @@ +"""Data retention (PI decision, 2026-07-10: "remove temp data after analysis"). + +Policy: + * ANONYMOUS runs: the uploaded corpus file (and its embedding cache) is + deleted the moment the run finishes (jobs.py calls remove_corpus_files). + Result summaries/CSVs stick around so the person can download them, then + the whole anonymous project is purged after CCR_ANON_TTL_HOURS. + * SIGNED-IN runs: nothing is auto-deleted; a saved-run cap applies instead + (enforced at job creation in main.py - the user chooses what to delete). + +The purge loop runs in a daemon thread (startup + hourly). TTL of 0 disables +purging entirely - the local-dev default, so nobody's dev projects vanish +overnight. Deployments set CCR_ANON_TTL_HOURS=24. +""" + +from __future__ import annotations + +import logging +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from sqlalchemy.orm import Session + +from . import auth, storage +from .db import DATA_DIR, SessionLocal +from .models import Corpus, Job, Project + +logger = logging.getLogger("ccr.retention") + +EMB_CACHE_DIR = DATA_DIR / "emb_cache" +EMB_CACHE_DIR.mkdir(exist_ok=True) + +_stop = threading.Event() +_thread: threading.Thread | None = None + + +def remove_corpus_files(corpus: Corpus) -> None: + """Delete the uploaded file (whatever backend holds it) and any cached + embeddings (always local - caches are derived data).""" + storage.delete(corpus.path) + for cached in EMB_CACHE_DIR.glob(f"{corpus.id}_*.npy"): + cached.unlink(missing_ok=True) + + +def delete_project_cascade(db: Session, project: Project) -> dict: + """Shared cascade used by the DELETE endpoint and the anonymous purge. + Removes DB rows plus uploaded, result, and embedding-cache files. Logs + counts only - never any uploaded text (design doc §9).""" + corpora = db.query(Corpus).filter_by(project_id=project.id).all() + jobs = db.query(Job).filter_by(project_id=project.id).all() + + for corpus in corpora: + remove_corpus_files(corpus) + for job in jobs: + storage.delete(job.result_path) + + for job in jobs: + db.delete(job) + for corpus in corpora: + db.delete(corpus) + db.delete(project) + db.commit() + return {"corpora": len(corpora), "runs": len(jobs)} + + +def purge_expired_anonymous(db: Session) -> int: + """Delete anonymous projects whose last activity is older than the TTL.""" + ttl = auth.anon_ttl_hours() + if ttl <= 0: + return 0 + cutoff = (datetime.now(timezone.utc) - timedelta(hours=ttl)).isoformat(timespec="seconds") + + purged = 0 + candidates = db.query(Project).filter(Project.owner_user_id == "").all() + for project in candidates: + latest_job = ( + db.query(Job.created_at) + .filter_by(project_id=project.id) + .order_by(Job.created_at.desc()) + .first() + ) + last_activity = max(project.created_at, latest_job[0]) if latest_job else project.created_at + if last_activity < cutoff: + counts = delete_project_cascade(db, project) + purged += 1 + logger.info( + "purged expired anonymous project id=%s (corpora=%d runs=%d, ttl=%dh)", + project.id, counts["corpora"], counts["runs"], ttl, + ) + return purged + + +def _loop(interval_seconds: int) -> None: + while not _stop.wait(interval_seconds): + db = SessionLocal() + try: + purge_expired_anonymous(db) + except Exception: + logger.exception("anonymous purge failed; will retry next cycle") + finally: + db.close() + + +def start_cleanup(interval_seconds: int = 3600) -> None: + """Run one purge now, then hourly in a daemon thread. No-op if TTL is 0.""" + global _thread + db = SessionLocal() + try: + purge_expired_anonymous(db) + except Exception: + logger.exception("startup anonymous purge failed") + finally: + db.close() + if auth.anon_ttl_hours() > 0 and (_thread is None or not _thread.is_alive()): + _stop.clear() + _thread = threading.Thread(target=_loop, args=(interval_seconds,), daemon=True, + name="ccr-retention") + _thread.start() + + +def stop_cleanup() -> None: + _stop.set() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a6f0f837a085f744449580efdf6e24fa8750041c..1aa465d237d6dfdd054a4a078688b3935e22606f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -13,6 +13,24 @@ class ProjectOut(BaseModel): name: str description: str created_at: str + last_activity_at: str = "" # latest run creation, else project creation + n_runs: int = 0 + archived: bool = False + + +class ProjectPatch(BaseModel): + archived: bool | None = None + + +class RegisterIn(BaseModel): + email: str = Field(min_length=3, max_length=255) + password: str = Field(min_length=8, max_length=200) + name: str = Field(min_length=1, max_length=120) + + +class LoginIn(BaseModel): + email: str = Field(min_length=3, max_length=255) + password: str = Field(min_length=1, max_length=200) class CorpusOut(BaseModel): @@ -32,6 +50,8 @@ class ConstructCreate(BaseModel): description: str = "" reference: str = "" items: list[str] = Field(min_length=1) + reverse_scored: list[bool] | None = None # parallel to items; defaults to all False + language: str = "en" class ConstructOut(BaseModel): @@ -40,7 +60,13 @@ class ConstructOut(BaseModel): description: str reference: str items: list[str] + reverse_scored: list[bool] = [] is_seed: bool + version: int = 1 + verification_status: str = "draft" + language: str = "en" + category: str = "" + item_hash: str = "" # first 16 hex chars for display class JobCreate(BaseModel): @@ -48,7 +74,8 @@ class JobCreate(BaseModel): corpus_id: str construct_id: str text_column: str - model_name: str = "sentence-transformers/all-MiniLM-L6-v2" + model_name: str = "all-minilm-l6-v2" # registry id (spec 0003) + language: str = "en" class JobOut(BaseModel): @@ -60,6 +87,7 @@ class JobOut(BaseModel): corpus_filename: str = "" text_column: str model_name: str + language: str = "en" status: str progress: float error: str diff --git a/backend/app/seed_constructs.py b/backend/app/seed_constructs.py deleted file mode 100644 index 387c1069d3d8318703a09c93c6986a2de30d7231..0000000000000000000000000000000000000000 --- a/backend/app/seed_constructs.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Seed construct library — psychometrically validated scales. - -IMPORTANT: item wordings below are seeded for demonstration. Before any -research use, verify each item verbatim against the cited original -publication (CCR's validity depends on using the validated instrument -as published). -""" - -SEED_CONSTRUCTS = [ - { - "name": "Satisfaction with Life", - "description": "Global cognitive judgment of one's life satisfaction (SWLS).", - "reference": "Diener, E., Emmons, R. A., Larsen, R. J., & Griffin, S. (1985). The Satisfaction with Life Scale. Journal of Personality Assessment, 49(1).", - "items": [ - "In most ways my life is close to my ideal.", - "The conditions of my life are excellent.", - "I am satisfied with my life.", - "So far I have gotten the important things I want in life.", - "If I could live my life over, I would change almost nothing.", - ], - }, - { - "name": "Moral Foundations — Care", - "description": "Concern with suffering, compassion, and protection of the vulnerable (MFQ Care/Harm foundation).", - "reference": "Graham, J., Nosek, B. A., Haidt, J., Iyer, R., Koleva, S., & Ditto, P. H. (2011). Mapping the moral domain. JPSP, 101(2). Verify items against the published MFQ.", - "items": [ - "Compassion for those who are suffering is the most crucial virtue.", - "One of the worst things a person could do is hurt a defenseless animal.", - "Whether or not someone suffered emotionally.", - "Whether or not someone cared for someone weak or vulnerable.", - ], - }, - { - "name": "Moral Foundations — Fairness", - "description": "Concern with justice, rights, and equal treatment (MFQ Fairness/Cheating foundation).", - "reference": "Graham, J., Nosek, B. A., Haidt, J., Iyer, R., Koleva, S., & Ditto, P. H. (2011). Mapping the moral domain. JPSP, 101(2). Verify items against the published MFQ.", - "items": [ - "Justice is the most important requirement for a society.", - "When the government makes laws, the number one principle should be ensuring that everyone is treated fairly.", - "Whether or not some people were treated differently than others.", - "Whether or not someone acted unfairly.", - ], - }, - { - "name": "Individualism (Horizontal)", - "description": "Self-reliance and independence from in-groups (Triandis & Gelfand horizontal individualism).", - "reference": "Triandis, H. C., & Gelfand, M. J. (1998). Converging measurement of horizontal and vertical individualism and collectivism. JPSP, 74(1). Verify items against the published scale.", - "items": [ - "I'd rather depend on myself than others.", - "I rely on myself most of the time; I rarely rely on others.", - "I often do my own thing.", - "My personal identity, independent of others, is very important to me.", - ], - }, - { - "name": "Collectivism (Horizontal)", - "description": "Interdependence, cooperation, and in-group well-being (Triandis & Gelfand horizontal collectivism).", - "reference": "Triandis, H. C., & Gelfand, M. J. (1998). Converging measurement of horizontal and vertical individualism and collectivism. JPSP, 74(1). Verify items against the published scale.", - "items": [ - "If a coworker gets a prize, I would feel proud.", - "The well-being of my coworkers is important to me.", - "To me, pleasure is spending time with others.", - "I feel good when I cooperate with others.", - ], - }, -] diff --git a/backend/app/storage.py b/backend/app/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..696da63a589de3eabc27404ed000b0cc372fea9d --- /dev/null +++ b/backend/app/storage.py @@ -0,0 +1,140 @@ +"""File storage behind one interface: local disk (default) or S3-compatible. + +Production-ready now, enabled by configuration at deploy time (Deva, +2026-07-13): the R2/S3 code path ships with the codebase so flipping a +production instance to object storage is an env change, never a development +task. Local dev keeps writing plain files under CCR_DATA_DIR. + +Locator scheme (stored in the DB's existing path columns - no migration): + * local backend: an absolute filesystem path (exactly as before); + * s3 backend: "s3://{key}" inside the configured bucket. +Old rows with absolute paths keep working even on an s3-configured instance. + +Config (s3 backend): CCR_STORAGE=s3, CCR_S3_ENDPOINT (R2: the account +endpoint URL), CCR_S3_BUCKET, CCR_S3_ACCESS_KEY_ID, CCR_S3_SECRET_ACCESS_KEY. +The bucket stays private; downloads stream through the API, so no public +access or presigned-URL exposure is required. + +Embedding caches deliberately stay on local disk: they are derived data, +cheap to recompute, and read with numpy - a cache does not need durability. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from .db import DATA_DIR + +S3_PREFIX = "s3://" + +_client = None # injectable for tests + + +def backend() -> str: + return os.environ.get("CCR_STORAGE", "local").lower() + + +def _s3(): + global _client + if _client is None: + import boto3 # lazy: only s3-configured deployments need it + + _client = boto3.client( + "s3", + endpoint_url=os.environ["CCR_S3_ENDPOINT"], + aws_access_key_id=os.environ["CCR_S3_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["CCR_S3_SECRET_ACCESS_KEY"], + region_name=os.environ.get("CCR_S3_REGION", "auto"), + ) + return _client + + +def _bucket() -> str: + return os.environ["CCR_S3_BUCKET"] + + +def is_s3(locator: str) -> bool: + return locator.startswith(S3_PREFIX) + + +# ------------------------------------------------------------------ write +def store_bytes(category: str, name: str, data: bytes) -> str: + """Persist bytes under category/name; return the locator to store in the DB.""" + key = f"{category}/{name}" + if backend() == "s3": + _s3().put_object(Bucket=_bucket(), Key=key, Body=data) + return S3_PREFIX + key + dest = DATA_DIR / category / name + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + return str(dest) + + +def store_file(category: str, name: str, src: Path) -> str: + return store_bytes(category, name, Path(src).read_bytes()) + + +# ------------------------------------------------------------------- read +def exists(locator: str) -> bool: + if not locator: + return False + if is_s3(locator): + try: + _s3().head_object(Bucket=_bucket(), Key=locator[len(S3_PREFIX):]) + return True + except Exception: + return False + return Path(locator).exists() + + +def fetch_to_local(locator: str) -> tuple[Path, bool]: + """Return (local_path, is_temporary). Caller unlinks temporary files + after use; local-backend paths are returned as-is.""" + if is_s3(locator): + key = locator[len(S3_PREFIX):] + suffix = Path(key).suffix or ".bin" + fd, tmp = tempfile.mkstemp(suffix=suffix, prefix="ccr_s3_") + os.close(fd) + _s3().download_file(_bucket(), key, tmp) + return Path(tmp), True + return Path(locator), False + + +def open_stream(locator: str): + """Iterator of byte chunks, for streaming downloads through the API.""" + if is_s3(locator): + body = _s3().get_object(Bucket=_bucket(), Key=locator[len(S3_PREFIX):])["Body"] + return iter(lambda: body.read(64 * 1024), b"") + fh = open(locator, "rb") + + def gen(): + with fh: + while chunk := fh.read(64 * 1024): + yield chunk + + return gen() + + +# ------------------------------------------------------------------ delete +def delete(locator: str) -> None: + if not locator: + return + if is_s3(locator): + try: + _s3().delete_object(Bucket=_bucket(), Key=locator[len(S3_PREFIX):]) + except Exception: + pass # deletion is best-effort; the TTL sweep retries implicitly + return + Path(locator).unlink(missing_ok=True) + + +def move_local_into_storage(category: str, name: str, local_path: Path) -> str: + """Store a locally produced file; the source copy is removed unless it IS + the stored destination (local backend writing in place).""" + locator = store_file(category, name, local_path) + src = Path(local_path) + if is_s3(locator) or (src.exists() and str(src.resolve()) != str(Path(locator).resolve())): + src.unlink(missing_ok=True) + return locator diff --git a/backend/app/warnings_engine.py b/backend/app/warnings_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..6481830f5984590e342770cddea33ed26f17e939 --- /dev/null +++ b/backend/app/warnings_engine.py @@ -0,0 +1,151 @@ +"""Structured data-quality warnings (spec 0001, design doc §12). + +Every warning is an object - {code, severity, message, count?, affected_rows_sample?} - +never a bare string. Codes are UPPER_SNAKE and stable: downstream notebooks and the UI +key off them. Severity: "info" (status, not a problem) | "warning" (proceed with care). +Language detection is corpus-level only; short texts are exactly where detection is +unreliable, so uncertainty is reported instead of guessed away. + +Deviation from spec 0001 (recorded there): langdetect instead of lingua - pure-Python, +~1 MB vs ~100 MB wheels; seeded for determinism. Upgrade path preserved by recording +detector + version in metadata. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +MIN_TOKENS_STABLE = 4 # texts below this are flagged TEXT_TOO_SHORT +DETECT_MIN_TOKENS = 5 # rows shorter than this are skipped for detection +DETECT_SAMPLE_MAX = 200 # rows sampled for corpus-level detection +DETECT_MIN_ROWS = 20 # fewer detectable rows -> LANGUAGE_UNCERTAIN +DETECT_CONFIDENCE = 0.70 # majority share below this -> LANGUAGE_UNCERTAIN +SAMPLE_ROWS_SHOWN = 5 + + +def warning(code: str, severity: str, message: str, **extra) -> dict: + return {"code": code, "severity": severity, "message": message, **extra} + + +# ---------------------------------------------------------------- text QA +def short_text_warning(texts: list[str]) -> dict | None: + idx = [i for i, t in enumerate(texts) if len(t.split()) < MIN_TOKENS_STABLE] + if not idx: + return None + return warning( + "TEXT_TOO_SHORT", + "warning", + f"{len(idx)} text(s) contain fewer than {MIN_TOKENS_STABLE} words; " + "CCR scores may be unstable for very short texts.", + count=len(idx), + affected_rows_sample=idx[:SAMPLE_ROWS_SHOWN], + ) + + +# ---------------------------------------------------------- language checks +@dataclass +class LanguageResult: + selected: str + detected: str | None + confidence: float | None + n_rows_sampled: int + detector: str + detector_version: str + + def as_metadata(self) -> dict: + return { + "selected": self.selected, + "detected": self.detected, + "confidence": self.confidence, + "n_rows_sampled": self.n_rows_sampled, + "detector": self.detector, + "detector_version": self.detector_version, + } + + +def detect_corpus_language(texts: list[str], selected: str) -> tuple[LanguageResult, list[dict]]: + """Corpus-level majority-vote detection on a sample of detectable rows.""" + from langdetect import DetectorFactory, detect # lazy import + from langdetect.lang_detect_exception import LangDetectException + + try: + from importlib.metadata import version as _v + + detector_version = _v("langdetect") + except Exception: + detector_version = "unknown" + + DetectorFactory.seed = 0 # determinism - same corpus, same result, every run + + detectable = [t for t in texts if len(t.split()) >= DETECT_MIN_TOKENS][:DETECT_SAMPLE_MAX] + warnings: list[dict] = [] + + if len(detectable) < DETECT_MIN_ROWS: + result = LanguageResult(selected, None, None, len(detectable), "langdetect", detector_version) + warnings.append( + warning( + "LANGUAGE_UNCERTAIN", + "info", + f"Language could not be determined confidently ({len(detectable)} detectable " + f"row(s), need {DETECT_MIN_ROWS}); language checks were skipped.", + ) + ) + return result, warnings + + votes: dict[str, int] = {} + for t in detectable: + try: + lang = detect(t) # one detection per row (detect() is the expensive call) + except LangDetectException: + continue + votes[lang] = votes.get(lang, 0) + 1 + + if not votes: + result = LanguageResult(selected, None, None, len(detectable), "langdetect", detector_version) + warnings.append( + warning("LANGUAGE_UNCERTAIN", "info", + "Language detection produced no result; language checks were skipped.") + ) + return result, warnings + + top_lang, top_count = max(votes.items(), key=lambda kv: kv[1]) + confidence = round(top_count / sum(votes.values()), 3) + result = LanguageResult(selected, top_lang, confidence, len(detectable), "langdetect", detector_version) + + if confidence < DETECT_CONFIDENCE: + warnings.append( + warning( + "LANGUAGE_UNCERTAIN", + "info", + f"Detected language is uncertain (top candidate '{top_lang}' at " + f"{confidence:.0%} of sampled rows); interpret language checks with care.", + ) + ) + elif top_lang != selected.lower(): + warnings.append( + warning( + "LANGUAGE_MISMATCH", + "warning", + f"You selected '{selected}', but the corpus appears to be '{top_lang}' " + f"({confidence:.0%} of {len(detectable)} sampled rows).", + detected_language=top_lang, + selected_language=selected, + ) + ) + return result, warnings + + +def model_language_warning(selected: str, model_id: str, supported: frozenset[str], + language_set_name: str | None) -> dict | None: + if not supported or selected.lower() in supported: + return None + label = f"the '{language_set_name}' language set" if language_set_name else \ + f"{sorted(supported)}" + return warning( + "MODEL_LANGUAGE_UNSUPPORTED", + "warning", + f"The selected model supports {label}, but you selected '{selected}'. " + "Switch to a multilingual model or proceed with caution.", + selected_language=selected, + model_id=model_id, + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index a80af30c344248fa203af485d588774a1311d774..7bb8765224735f3591618ffbcc40699effcca78f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,6 @@ openpyxl>=3.1 python-multipart>=0.0.9 numpy>=1.26 sentence-transformers>=2.6 +pyyaml>=6.0 +langdetect>=1.0.9 +boto3>=1.34 # used only when CCR_STORAGE=s3 (Cloudflare R2 / any S3-compatible store) diff --git a/backend/static/assets/index-BUzS6usZ.js b/backend/static/assets/index-BUzS6usZ.js deleted file mode 100644 index 70ab5a1962c5444108239a1d3afd3ec50540a26f..0000000000000000000000000000000000000000 --- a/backend/static/assets/index-BUzS6usZ.js +++ /dev/null @@ -1,42 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const u of i.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&r(u)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function oc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Yo={exports:{}},ol={},Go={exports:{}},L={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var nr=Symbol.for("react.element"),sc=Symbol.for("react.portal"),ac=Symbol.for("react.fragment"),cc=Symbol.for("react.strict_mode"),fc=Symbol.for("react.profiler"),dc=Symbol.for("react.provider"),pc=Symbol.for("react.context"),mc=Symbol.for("react.forward_ref"),hc=Symbol.for("react.suspense"),vc=Symbol.for("react.memo"),yc=Symbol.for("react.lazy"),Iu=Symbol.iterator;function gc(e){return e===null||typeof e!="object"?null:(e=Iu&&e[Iu]||e["@@iterator"],typeof e=="function"?e:null)}var Jo={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Zo=Object.assign,qo={};function pn(e,t,n){this.props=e,this.context=t,this.refs=qo,this.updater=n||Jo}pn.prototype.isReactComponent={};pn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};pn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function bo(){}bo.prototype=pn.prototype;function Hi(e,t,n){this.props=e,this.context=t,this.refs=qo,this.updater=n||Jo}var Wi=Hi.prototype=new bo;Wi.constructor=Hi;Zo(Wi,pn.prototype);Wi.isPureReactComponent=!0;var Uu=Array.isArray,es=Object.prototype.hasOwnProperty,Qi={current:null},ts={key:!0,ref:!0,__self:!0,__source:!0};function ns(e,t,n){var r,l={},i=null,u=null;if(t!=null)for(r in t.ref!==void 0&&(u=t.ref),t.key!==void 0&&(i=""+t.key),t)es.call(t,r)&&!ts.hasOwnProperty(r)&&(l[r]=t[r]);var o=arguments.length-2;if(o===1)l.children=n;else if(1>>1,K=_[D];if(0>>1;D<$e;){var Qe=2*(D+1)-1,Cl=_[Qe],Ct=Qe+1,or=_[Ct];if(0>l(Cl,y))Ctl(or,Cl)?(_[D]=or,_[Ct]=y,D=Ct):(_[D]=Cl,_[Qe]=y,D=Qe);else if(Ctl(or,y))_[D]=or,_[Ct]=y,D=Ct;else break e}}return z}function l(_,z){var y=_.sortIndex-z.sortIndex;return y!==0?y:_.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var u=Date,o=u.now();e.unstable_now=function(){return u.now()-o}}var s=[],c=[],h=1,m=null,v=3,g=!1,x=!1,k=!1,F=typeof setTimeout=="function"?setTimeout:null,d=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function p(_){for(var z=n(c);z!==null;){if(z.callback===null)r(c);else if(z.startTime<=_)r(c),z.sortIndex=z.expirationTime,t(s,z);else break;z=n(c)}}function w(_){if(k=!1,p(_),!x)if(n(s)!==null)x=!0,vn(C);else{var z=n(c);z!==null&&yn(w,z.startTime-_)}}function C(_,z){x=!1,k&&(k=!1,d(P),P=-1),g=!0;var y=v;try{for(p(z),m=n(s);m!==null&&(!(m.expirationTime>z)||_&&!ue());){var D=m.callback;if(typeof D=="function"){m.callback=null,v=m.priorityLevel;var K=D(m.expirationTime<=z);z=e.unstable_now(),typeof K=="function"?m.callback=K:m===n(s)&&r(s),p(z)}else r(s);m=n(s)}if(m!==null)var $e=!0;else{var Qe=n(c);Qe!==null&&yn(w,Qe.startTime-z),$e=!1}return $e}finally{m=null,v=y,g=!1}}var E=!1,j=null,P=-1,A=5,T=-1;function ue(){return!(e.unstable_now()-T_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):A=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return v},e.unstable_getFirstCallbackNode=function(){return n(s)},e.unstable_next=function(_){switch(v){case 1:case 2:case 3:var z=3;break;default:z=v}var y=v;v=z;try{return _()}finally{v=y}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,z){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var y=v;v=_;try{return z()}finally{v=y}},e.unstable_scheduleCallback=function(_,z,y){var D=e.unstable_now();switch(typeof y=="object"&&y!==null?(y=y.delay,y=typeof y=="number"&&0D?(_.sortIndex=y,t(c,_),n(s)===null&&_===n(c)&&(k?(d(P),P=-1):k=!0,yn(w,y-D))):(_.sortIndex=K,t(s,_),x||g||(x=!0,vn(C))),_},e.unstable_shouldYield=ue,e.unstable_wrapCallback=function(_){var z=v;return function(){var y=v;v=z;try{return _.apply(this,arguments)}finally{v=y}}}})(os);us.exports=os;var Tc=us.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Lc=R,Se=Tc;function S(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Zl=Object.prototype.hasOwnProperty,Rc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Au={},Vu={};function Mc(e){return Zl.call(Vu,e)?!0:Zl.call(Au,e)?!1:Rc.test(e)?Vu[e]=!0:(Au[e]=!0,!1)}function Oc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Dc(e,t,n,r){if(t===null||typeof t>"u"||Oc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ce(e,t,n,r,l,i,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=u}var te={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){te[e]=new ce(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];te[t]=new ce(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){te[e]=new ce(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){te[e]=new ce(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){te[e]=new ce(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){te[e]=new ce(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){te[e]=new ce(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){te[e]=new ce(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){te[e]=new ce(e,5,!1,e.toLowerCase(),null,!1,!1)});var Xi=/[\-:]([a-z])/g;function Yi(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Xi,Yi);te[t]=new ce(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Xi,Yi);te[t]=new ce(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Xi,Yi);te[t]=new ce(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){te[e]=new ce(e,1,!1,e.toLowerCase(),null,!1,!1)});te.xlinkHref=new ce("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){te[e]=new ce(e,1,!1,e.toLowerCase(),null,!0,!0)});function Gi(e,t,n,r){var l=te.hasOwnProperty(t)?te[t]:null;(l!==null?l.type!==0:r||!(2o||l[u]!==i[o]){var s=` -`+l[u].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=u&&0<=o);break}}}finally{Nl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Nn(e):""}function Fc(e){switch(e.tag){case 5:return Nn(e.type);case 16:return Nn("Lazy");case 13:return Nn("Suspense");case 19:return Nn("SuspenseList");case 0:case 2:case 15:return e=jl(e.type,!1),e;case 11:return e=jl(e.type.render,!1),e;case 1:return e=jl(e.type,!0),e;default:return""}}function ti(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Bt:return"Fragment";case Vt:return"Portal";case ql:return"Profiler";case Ji:return"StrictMode";case bl:return"Suspense";case ei:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case cs:return(e.displayName||"Context")+".Consumer";case as:return(e._context.displayName||"Context")+".Provider";case Zi:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case qi:return t=e.displayName||null,t!==null?t:ti(e.type)||"Memo";case lt:t=e._payload,e=e._init;try{return ti(e(t))}catch{}}return null}function Ic(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ti(t);case 8:return t===Ji?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function gt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ds(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Uc(e){var t=ds(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,i.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function cr(e){e._valueTracker||(e._valueTracker=Uc(e))}function ps(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ds(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Ir(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ni(e,t){var n=t.checked;return W({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Hu(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=gt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ms(e,t){t=t.checked,t!=null&&Gi(e,"checked",t,!1)}function ri(e,t){ms(e,t);var n=gt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?li(e,t.type,n):t.hasOwnProperty("defaultValue")&&li(e,t.type,gt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Wu(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function li(e,t,n){(t!=="number"||Ir(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var jn=Array.isArray;function bt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=fr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function An(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Tn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$c=["Webkit","ms","Moz","O"];Object.keys(Tn).forEach(function(e){$c.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Tn[t]=Tn[e]})});function gs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Tn.hasOwnProperty(e)&&Tn[e]?(""+t).trim():t+"px"}function ws(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=gs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Ac=W({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function oi(e,t){if(t){if(Ac[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(S(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(S(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(S(61))}if(t.style!=null&&typeof t.style!="object")throw Error(S(62))}}function si(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ai=null;function bi(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ci=null,en=null,tn=null;function Xu(e){if(e=ir(e)){if(typeof ci!="function")throw Error(S(280));var t=e.stateNode;t&&(t=dl(t),ci(e.stateNode,e.type,t))}}function Ss(e){en?tn?tn.push(e):tn=[e]:en=e}function xs(){if(en){var e=en,t=tn;if(tn=en=null,Xu(e),t)for(e=0;e>>=0,e===0?32:31-(Zc(e)/qc|0)|0}var dr=64,pr=4194304;function Pn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Vr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,u=n&268435455;if(u!==0){var o=u&~l;o!==0?r=Pn(o):(i&=u,i!==0&&(r=Pn(i)))}else u=n&~l,u!==0?r=Pn(u):i!==0&&(r=Pn(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function rr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Fe(t),e[t]=n}function nf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Rn),no=" ",ro=!1;function Vs(e,t){switch(e){case"keyup":return Lf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Bs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Ht=!1;function Mf(e,t){switch(e){case"compositionend":return Bs(t);case"keypress":return t.which!==32?null:(ro=!0,no);case"textInput":return e=t.data,e===no&&ro?null:e;default:return null}}function Of(e,t){if(Ht)return e==="compositionend"||!ou&&Vs(e,t)?(e=$s(),Pr=lu=st=null,Ht=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=oo(n)}}function Ks(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ks(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Xs(){for(var e=window,t=Ir();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ir(e.document)}return t}function su(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Hf(e){var t=Xs(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ks(n.ownerDocument.documentElement,n)){if(r!==null&&su(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=so(n,i);var u=so(n,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Wt=null,vi=null,On=null,yi=!1;function ao(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;yi||Wt==null||Wt!==Ir(r)||(r=Wt,"selectionStart"in r&&su(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),On&&Kn(On,r)||(On=r,r=Wr(vi,"onSelect"),0Xt||(e.current=Ci[Xt],Ci[Xt]=null,Xt--)}function I(e,t){Xt++,Ci[Xt]=e.current,e.current=t}var wt={},ie=xt(wt),me=xt(!1),Lt=wt;function on(e,t){var n=e.type.contextTypes;if(!n)return wt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function he(e){return e=e.childContextTypes,e!=null}function Kr(){$(me),$(ie)}function yo(e,t,n){if(ie.current!==wt)throw Error(S(168));I(ie,t),I(me,n)}function na(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(S(108,Ic(e)||"Unknown",l));return W({},n,r)}function Xr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||wt,Lt=ie.current,I(ie,e),I(me,me.current),!0}function go(e,t,n){var r=e.stateNode;if(!r)throw Error(S(169));n?(e=na(e,t,Lt),r.__reactInternalMemoizedMergedChildContext=e,$(me),$(ie),I(ie,e)):$(me),I(me,n)}var Xe=null,pl=!1,Vl=!1;function ra(e){Xe===null?Xe=[e]:Xe.push(e)}function td(e){pl=!0,ra(e)}function kt(){if(!Vl&&Xe!==null){Vl=!0;var e=0,t=O;try{var n=Xe;for(O=1;e>=u,l-=u,Ye=1<<32-Fe(t)+l|n<P?(A=j,j=null):A=j.sibling;var T=v(d,j,p[P],w);if(T===null){j===null&&(j=A);break}e&&j&&T.alternate===null&&t(d,j),a=i(T,a,P),E===null?C=T:E.sibling=T,E=T,j=A}if(P===p.length)return n(d,j),V&&Et(d,P),C;if(j===null){for(;PP?(A=j,j=null):A=j.sibling;var ue=v(d,j,T.value,w);if(ue===null){j===null&&(j=A);break}e&&j&&ue.alternate===null&&t(d,j),a=i(ue,a,P),E===null?C=ue:E.sibling=ue,E=ue,j=A}if(T.done)return n(d,j),V&&Et(d,P),C;if(j===null){for(;!T.done;P++,T=p.next())T=m(d,T.value,w),T!==null&&(a=i(T,a,P),E===null?C=T:E.sibling=T,E=T);return V&&Et(d,P),C}for(j=r(d,j);!T.done;P++,T=p.next())T=g(j,d,P,T.value,w),T!==null&&(e&&T.alternate!==null&&j.delete(T.key===null?P:T.key),a=i(T,a,P),E===null?C=T:E.sibling=T,E=T);return e&&j.forEach(function(Ce){return t(d,Ce)}),V&&Et(d,P),C}function F(d,a,p,w){if(typeof p=="object"&&p!==null&&p.type===Bt&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case ar:e:{for(var C=p.key,E=a;E!==null;){if(E.key===C){if(C=p.type,C===Bt){if(E.tag===7){n(d,E.sibling),a=l(E,p.props.children),a.return=d,d=a;break e}}else if(E.elementType===C||typeof C=="object"&&C!==null&&C.$$typeof===lt&&xo(C)===E.type){n(d,E.sibling),a=l(E,p.props),a.ref=Cn(d,E,p),a.return=d,d=a;break e}n(d,E);break}else t(d,E);E=E.sibling}p.type===Bt?(a=Tt(p.props.children,d.mode,w,p.key),a.return=d,d=a):(w=Fr(p.type,p.key,p.props,null,d.mode,w),w.ref=Cn(d,a,p),w.return=d,d=w)}return u(d);case Vt:e:{for(E=p.key;a!==null;){if(a.key===E)if(a.tag===4&&a.stateNode.containerInfo===p.containerInfo&&a.stateNode.implementation===p.implementation){n(d,a.sibling),a=l(a,p.children||[]),a.return=d,d=a;break e}else{n(d,a);break}else t(d,a);a=a.sibling}a=Gl(p,d.mode,w),a.return=d,d=a}return u(d);case lt:return E=p._init,F(d,a,E(p._payload),w)}if(jn(p))return x(d,a,p,w);if(gn(p))return k(d,a,p,w);Sr(d,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,a!==null&&a.tag===6?(n(d,a.sibling),a=l(a,p),a.return=d,d=a):(n(d,a),a=Yl(p,d.mode,w),a.return=d,d=a),u(d)):n(d,a)}return F}var an=oa(!0),sa=oa(!1),Jr=xt(null),Zr=null,Jt=null,du=null;function pu(){du=Jt=Zr=null}function mu(e){var t=Jr.current;$(Jr),e._currentValue=t}function Ni(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function rn(e,t){Zr=e,du=Jt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(pe=!0),e.firstContext=null)}function Te(e){var t=e._currentValue;if(du!==e)if(e={context:e,memoizedValue:t,next:null},Jt===null){if(Zr===null)throw Error(S(308));Jt=e,Zr.dependencies={lanes:0,firstContext:e}}else Jt=Jt.next=e;return t}var jt=null;function hu(e){jt===null?jt=[e]:jt.push(e)}function aa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,hu(t)):(n.next=l.next,l.next=n),t.interleaved=n,be(e,r)}function be(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var it=!1;function vu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ca(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Je(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function mt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,M&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,be(e,n)}return l=r.interleaved,l===null?(t.next=t,hu(r)):(t.next=l.next,l.next=t),r.interleaved=t,be(e,n)}function Tr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,tu(e,n)}}function ko(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=u:i=i.next=u,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function qr(e,t,n,r){var l=e.updateQueue;it=!1;var i=l.firstBaseUpdate,u=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var s=o,c=s.next;s.next=null,u===null?i=c:u.next=c,u=s;var h=e.alternate;h!==null&&(h=h.updateQueue,o=h.lastBaseUpdate,o!==u&&(o===null?h.firstBaseUpdate=c:o.next=c,h.lastBaseUpdate=s))}if(i!==null){var m=l.baseState;u=0,h=c=s=null,o=i;do{var v=o.lane,g=o.eventTime;if((r&v)===v){h!==null&&(h=h.next={eventTime:g,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var x=e,k=o;switch(v=t,g=n,k.tag){case 1:if(x=k.payload,typeof x=="function"){m=x.call(g,m,v);break e}m=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=k.payload,v=typeof x=="function"?x.call(g,m,v):x,v==null)break e;m=W({},m,v);break e;case 2:it=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,v=l.effects,v===null?l.effects=[o]:v.push(o))}else g={eventTime:g,lane:v,tag:o.tag,payload:o.payload,callback:o.callback,next:null},h===null?(c=h=g,s=m):h=h.next=g,u|=v;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;v=o,o=v.next,v.next=null,l.lastBaseUpdate=v,l.shared.pending=null}}while(!0);if(h===null&&(s=m),l.baseState=s,l.firstBaseUpdate=c,l.lastBaseUpdate=h,t=l.shared.interleaved,t!==null){l=t;do u|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);Ot|=u,e.lanes=u,e.memoizedState=m}}function Co(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Hl.transition;Hl.transition={};try{e(!1),t()}finally{O=n,Hl.transition=r}}function ja(){return Le().memoizedState}function id(e,t,n){var r=vt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Pa(e))za(t,n);else if(n=aa(e,t,n,r),n!==null){var l=se();Ie(n,e,r,l),Ta(n,t,r)}}function ud(e,t,n){var r=vt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Pa(e))za(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var u=t.lastRenderedState,o=i(u,n);if(l.hasEagerState=!0,l.eagerState=o,Ue(o,u)){var s=t.interleaved;s===null?(l.next=l,hu(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=aa(e,t,l,r),n!==null&&(l=se(),Ie(n,e,r,l),Ta(n,t,r))}}function Pa(e){var t=e.alternate;return e===H||t!==null&&t===H}function za(e,t){Dn=el=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ta(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,tu(e,n)}}var tl={readContext:Te,useCallback:ne,useContext:ne,useEffect:ne,useImperativeHandle:ne,useInsertionEffect:ne,useLayoutEffect:ne,useMemo:ne,useReducer:ne,useRef:ne,useState:ne,useDebugValue:ne,useDeferredValue:ne,useTransition:ne,useMutableSource:ne,useSyncExternalStore:ne,useId:ne,unstable_isNewReconciler:!1},od={readContext:Te,useCallback:function(e,t){return Ve().memoizedState=[e,t===void 0?null:t],e},useContext:Te,useEffect:_o,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Rr(4194308,4,ka.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Rr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Rr(4,2,e,t)},useMemo:function(e,t){var n=Ve();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ve();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=id.bind(null,H,e),[r.memoizedState,e]},useRef:function(e){var t=Ve();return e={current:e},t.memoizedState=e},useState:Eo,useDebugValue:Eu,useDeferredValue:function(e){return Ve().memoizedState=e},useTransition:function(){var e=Eo(!1),t=e[0];return e=ld.bind(null,e[1]),Ve().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=H,l=Ve();if(V){if(n===void 0)throw Error(S(407));n=n()}else{if(n=t(),q===null)throw Error(S(349));Mt&30||ma(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,_o(va.bind(null,r,i,e),[e]),r.flags|=2048,er(9,ha.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Ve(),t=q.identifierPrefix;if(V){var n=Ge,r=Ye;n=(r&~(1<<32-Fe(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=qn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[Be]=t,e[Gn]=r,Aa(e,t,!1,!1),t.stateNode=e;e:{switch(u=si(n,r),n){case"dialog":U("cancel",e),U("close",e),l=r;break;case"iframe":case"object":case"embed":U("load",e),l=r;break;case"video":case"audio":for(l=0;ldn&&(t.flags|=128,r=!0,En(i,!1),t.lanes=4194304)}else{if(!r)if(e=br(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),En(i,!0),i.tail===null&&i.tailMode==="hidden"&&!u.alternate&&!V)return re(t),null}else 2*X()-i.renderingStartTime>dn&&n!==1073741824&&(t.flags|=128,r=!0,En(i,!1),t.lanes=4194304);i.isBackwards?(u.sibling=t.child,t.child=u):(n=i.last,n!==null?n.sibling=u:t.child=u,i.last=u)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=X(),t.sibling=null,n=B.current,I(B,r?n&1|2:n&1),t):(re(t),null);case 22:case 23:return Tu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ye&1073741824&&(re(t),t.subtreeFlags&6&&(t.flags|=8192)):re(t),null;case 24:return null;case 25:return null}throw Error(S(156,t.tag))}function hd(e,t){switch(cu(t),t.tag){case 1:return he(t.type)&&Kr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return cn(),$(me),$(ie),wu(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return gu(t),null;case 13:if($(B),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(S(340));sn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return $(B),null;case 4:return cn(),null;case 10:return mu(t.type._context),null;case 22:case 23:return Tu(),null;case 24:return null;default:return null}}var kr=!1,le=!1,vd=typeof WeakSet=="function"?WeakSet:Set,N=null;function Zt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Q(e,t,r)}else n.current=null}function Di(e,t,n){try{n()}catch(r){Q(e,t,r)}}var Fo=!1;function yd(e,t){if(gi=Br,e=Xs(),su(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var u=0,o=-1,s=-1,c=0,h=0,m=e,v=null;t:for(;;){for(var g;m!==n||l!==0&&m.nodeType!==3||(o=u+l),m!==i||r!==0&&m.nodeType!==3||(s=u+r),m.nodeType===3&&(u+=m.nodeValue.length),(g=m.firstChild)!==null;)v=m,m=g;for(;;){if(m===e)break t;if(v===n&&++c===l&&(o=u),v===i&&++h===r&&(s=u),(g=m.nextSibling)!==null)break;m=v,v=m.parentNode}m=g}n=o===-1||s===-1?null:{start:o,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(wi={focusedElem:e,selectionRange:n},Br=!1,N=t;N!==null;)if(t=N,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,N=e;else for(;N!==null;){t=N;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var k=x.memoizedProps,F=x.memoizedState,d=t.stateNode,a=d.getSnapshotBeforeUpdate(t.elementType===t.type?k:Me(t.type,k),F);d.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(S(163))}}catch(w){Q(t,t.return,w)}if(e=t.sibling,e!==null){e.return=t.return,N=e;break}N=t.return}return x=Fo,Fo=!1,x}function Fn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Di(t,n,i)}l=l.next}while(l!==r)}}function vl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Fi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ha(e){var t=e.alternate;t!==null&&(e.alternate=null,Ha(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Be],delete t[Gn],delete t[ki],delete t[bf],delete t[ed])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Wa(e){return e.tag===5||e.tag===3||e.tag===4}function Io(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Wa(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ii(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Qr));else if(r!==4&&(e=e.child,e!==null))for(Ii(e,t,n),e=e.sibling;e!==null;)Ii(e,t,n),e=e.sibling}function Ui(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ui(e,t,n),e=e.sibling;e!==null;)Ui(e,t,n),e=e.sibling}var b=null,Oe=!1;function rt(e,t,n){for(n=n.child;n!==null;)Qa(e,t,n),n=n.sibling}function Qa(e,t,n){if(He&&typeof He.onCommitFiberUnmount=="function")try{He.onCommitFiberUnmount(sl,n)}catch{}switch(n.tag){case 5:le||Zt(n,t);case 6:var r=b,l=Oe;b=null,rt(e,t,n),b=r,Oe=l,b!==null&&(Oe?(e=b,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):b.removeChild(n.stateNode));break;case 18:b!==null&&(Oe?(e=b,n=n.stateNode,e.nodeType===8?Al(e.parentNode,n):e.nodeType===1&&Al(e,n),Wn(e)):Al(b,n.stateNode));break;case 4:r=b,l=Oe,b=n.stateNode.containerInfo,Oe=!0,rt(e,t,n),b=r,Oe=l;break;case 0:case 11:case 14:case 15:if(!le&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,u=i.destroy;i=i.tag,u!==void 0&&(i&2||i&4)&&Di(n,t,u),l=l.next}while(l!==r)}rt(e,t,n);break;case 1:if(!le&&(Zt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){Q(n,t,o)}rt(e,t,n);break;case 21:rt(e,t,n);break;case 22:n.mode&1?(le=(r=le)||n.memoizedState!==null,rt(e,t,n),le=r):rt(e,t,n);break;default:rt(e,t,n)}}function Uo(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new vd),t.forEach(function(r){var l=Nd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Re(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=u),r&=~i}if(r=l,r=X()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*wd(r/1960))-r,10e?16:e,at===null)var r=!1;else{if(e=at,at=null,ll=0,M&6)throw Error(S(331));var l=M;for(M|=4,N=e.current;N!==null;){var i=N,u=i.child;if(N.flags&16){var o=i.deletions;if(o!==null){for(var s=0;sX()-Pu?zt(e,0):ju|=n),ve(e,t)}function ba(e,t){t===0&&(e.mode&1?(t=pr,pr<<=1,!(pr&130023424)&&(pr=4194304)):t=1);var n=se();e=be(e,t),e!==null&&(rr(e,t,n),ve(e,n))}function _d(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ba(e,n)}function Nd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(S(314))}r!==null&&r.delete(t),ba(e,n)}var ec;ec=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||me.current)pe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return pe=!1,pd(e,t,n);pe=!!(e.flags&131072)}else pe=!1,V&&t.flags&1048576&&la(t,Gr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Mr(e,t),e=t.pendingProps;var l=on(t,ie.current);rn(t,n),l=xu(null,t,r,e,l,n);var i=ku();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,he(r)?(i=!0,Xr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,vu(t),l.updater=hl,t.stateNode=l,l._reactInternals=t,Pi(t,r,e,n),t=Li(null,t,r,!0,i,n)):(t.tag=0,V&&i&&au(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Mr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Pd(r),e=Me(r,e),l){case 0:t=Ti(null,t,r,e,n);break e;case 1:t=Mo(null,t,r,e,n);break e;case 11:t=Lo(null,t,r,e,n);break e;case 14:t=Ro(null,t,r,Me(r.type,e),n);break e}throw Error(S(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Me(r,l),Ti(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Me(r,l),Mo(e,t,r,l,n);case 3:e:{if(Ia(t),e===null)throw Error(S(387));r=t.pendingProps,i=t.memoizedState,l=i.element,ca(e,t),qr(t,r,null,n);var u=t.memoizedState;if(r=u.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=fn(Error(S(423)),t),t=Oo(e,t,r,n,l);break e}else if(r!==l){l=fn(Error(S(424)),t),t=Oo(e,t,r,n,l);break e}else for(ge=pt(t.stateNode.containerInfo.firstChild),we=t,V=!0,De=null,n=sa(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(sn(),r===l){t=et(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return fa(t),e===null&&_i(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,u=l.children,Si(r,l)?u=null:i!==null&&Si(r,i)&&(t.flags|=32),Fa(e,t),oe(e,t,u,n),t.child;case 6:return e===null&&_i(t),null;case 13:return Ua(e,t,n);case 4:return yu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=an(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Me(r,l),Lo(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,u=l.value,I(Jr,r._currentValue),r._currentValue=u,i!==null)if(Ue(i.value,u)){if(i.children===l.children&&!me.current){t=et(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var o=i.dependencies;if(o!==null){u=i.child;for(var s=o.firstContext;s!==null;){if(s.context===r){if(i.tag===1){s=Je(-1,n&-n),s.tag=2;var c=i.updateQueue;if(c!==null){c=c.shared;var h=c.pending;h===null?s.next=s:(s.next=h.next,h.next=s),c.pending=s}}i.lanes|=n,s=i.alternate,s!==null&&(s.lanes|=n),Ni(i.return,n,t),o.lanes|=n;break}s=s.next}}else if(i.tag===10)u=i.type===t.type?null:i.child;else if(i.tag===18){if(u=i.return,u===null)throw Error(S(341));u.lanes|=n,o=u.alternate,o!==null&&(o.lanes|=n),Ni(u,n,t),u=i.sibling}else u=i.child;if(u!==null)u.return=i;else for(u=i;u!==null;){if(u===t){u=null;break}if(i=u.sibling,i!==null){i.return=u.return,u=i;break}u=u.return}i=u}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,rn(t,n),l=Te(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=Me(r,t.pendingProps),l=Me(r.type,l),Ro(e,t,r,l,n);case 15:return Oa(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Me(r,l),Mr(e,t),t.tag=1,he(r)?(e=!0,Xr(t)):e=!1,rn(t,n),La(t,r,l),Pi(t,r,l,n),Li(null,t,r,!0,e,n);case 19:return $a(e,t,n);case 22:return Da(e,t,n)}throw Error(S(156,t.tag))};function tc(e,t){return Ps(e,t)}function jd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Pe(e,t,n,r){return new jd(e,t,n,r)}function Ru(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Pd(e){if(typeof e=="function")return Ru(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Zi)return 11;if(e===qi)return 14}return 2}function yt(e,t){var n=e.alternate;return n===null?(n=Pe(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Fr(e,t,n,r,l,i){var u=2;if(r=e,typeof e=="function")Ru(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Bt:return Tt(n.children,l,i,t);case Ji:u=8,l|=8;break;case ql:return e=Pe(12,n,t,l|2),e.elementType=ql,e.lanes=i,e;case bl:return e=Pe(13,n,t,l),e.elementType=bl,e.lanes=i,e;case ei:return e=Pe(19,n,t,l),e.elementType=ei,e.lanes=i,e;case fs:return gl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case as:u=10;break e;case cs:u=9;break e;case Zi:u=11;break e;case qi:u=14;break e;case lt:u=16,r=null;break e}throw Error(S(130,e==null?e:typeof e,""))}return t=Pe(u,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Tt(e,t,n,r){return e=Pe(7,e,r,t),e.lanes=n,e}function gl(e,t,n,r){return e=Pe(22,e,r,t),e.elementType=fs,e.lanes=n,e.stateNode={isHidden:!1},e}function Yl(e,t,n){return e=Pe(6,e,null,t),e.lanes=n,e}function Gl(e,t,n){return t=Pe(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function zd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=zl(0),this.expirationTimes=zl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=zl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Mu(e,t,n,r,l,i,u,o,s){return e=new zd(e,t,n,o,s),t===1?(t=1,i===!0&&(t|=8)):t=0,i=Pe(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},vu(i),e}function Td(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(ic)}catch(e){console.error(e)}}ic(),is.exports=xe;var Dd=is.exports,uc,Ko=Dd;uc=Ko.createRoot,Ko.hydrateRoot;async function _e(e,t={}){const n=await fetch(e,t);if(!n.ok){let r=n.statusText;try{r=(await n.json()).detail||r}catch{}throw new Error(r)}return n.json()}const Jl=(e,t)=>({method:e,headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),fe={health:()=>_e("/api/health"),models:()=>_e("/api/models"),listProjects:()=>_e("/api/projects"),createProject:e=>_e("/api/projects",Jl("POST",e)),listCorpora:e=>_e(`/api/projects/${e}/corpora`),uploadCorpus:(e,t)=>{const n=new FormData;return n.append("file",t),_e(`/api/projects/${e}/corpora`,{method:"POST",body:n})},listConstructs:()=>_e("/api/constructs"),createConstruct:e=>_e("/api/constructs",Jl("POST",e)),createJob:e=>_e("/api/jobs",Jl("POST",e)),listJobs:e=>_e(`/api/jobs?project_id=${e}`),getJob:e=>_e(`/api/jobs/${e}`),jobResults:e=>_e(`/api/jobs/${e}/results`),exportUrl:e=>`/api/jobs/${e}/export`,metadataUrl:e=>`/api/jobs/${e}/metadata`};function Fd({jobId:e,onBack:t}){var c;const[n,r]=R.useState(null),[l,i]=R.useState("");if(R.useEffect(()=>{fe.jobResults(e).then(r).catch(h=>i(h.message))},[e]),l)return f.jsxs("div",{className:"card",children:[f.jsx("div",{className:"error-banner",children:l}),f.jsx("button",{className:"ghost",onClick:t,children:"← Back"})]});if(!n)return f.jsx("div",{className:"card",children:"Loading results…"});const{summary:u,metadata:o}=n,s=Math.max(...u.item_means.map(h=>Math.abs(h.mean)),1e-9);return f.jsxs(f.Fragment,{children:[f.jsxs("div",{className:"row",style:{justifyContent:"space-between",marginBottom:14},children:[f.jsx("button",{className:"ghost",onClick:t,children:"← Back to workspace"}),f.jsxs("div",{className:"row",children:[f.jsx("a",{href:fe.exportUrl(e),children:f.jsx("button",{className:"primary",children:"Export results CSV"})}),f.jsx("a",{href:fe.metadataUrl(e),children:f.jsx("button",{className:"ghost",children:"Run metadata (JSON)"})})]})]}),f.jsxs("div",{className:"card",children:[f.jsxs("h3",{children:[o.construct," × ",o.corpus_file]}),f.jsx("p",{className:"hint",children:"CCR score = mean cosine similarity between each text and the construct's scale items. Higher = the text expresses the construct more strongly."}),f.jsxs("div",{className:"stat-grid",children:[f.jsx(At,{k:"Texts scored",v:u.n_docs.toLocaleString()}),f.jsx(At,{k:"Mean score",v:u.score_mean.toFixed(3)}),f.jsx(At,{k:"SD",v:u.score_sd.toFixed(3)}),f.jsx(At,{k:"Min",v:u.score_min.toFixed(3)}),f.jsx(At,{k:"Max",v:u.score_max.toFixed(3)}),u.n_dropped_empty>0&&f.jsx(At,{k:"Empty rows dropped",v:u.n_dropped_empty})]}),((c=u.warnings)==null?void 0:c.length)>0&&f.jsxs("div",{className:"warnings mt",children:[f.jsx("strong",{className:"small",children:"Data-quality notes"}),f.jsx("ul",{className:"small",style:{margin:"4px 0 0",paddingLeft:20},children:u.warnings.map((h,m)=>f.jsx("li",{children:h},m))})]})]}),f.jsxs("div",{className:"card",children:[f.jsx("h3",{children:"Score distribution"}),f.jsx(Id,{histogram:u.histogram})]}),f.jsxs("div",{className:"card",children:[f.jsx("h3",{children:"Per-item mean loadings"}),f.jsx("p",{className:"hint",children:"Mean similarity of the corpus to each scale item — a face-validity check on which items drive the construct signal."}),u.item_means.map((h,m)=>f.jsxs("div",{className:"item-bar-row",children:[f.jsx("span",{className:"item-bar-label",title:h.item,children:h.item.length>80?h.item.slice(0,80)+"…":h.item}),f.jsx("div",{className:"item-bar-track",children:f.jsx("div",{className:"item-bar-fill",style:{width:`${Math.max(2,Math.abs(h.mean)/s*100)}%`}})}),f.jsx("span",{className:"item-bar-val",children:h.mean.toFixed(3)})]},m))]}),f.jsxs("div",{className:"row",children:[f.jsxs("div",{className:"grow card",children:[f.jsx("h3",{children:"Highest-scoring texts"}),f.jsx(Xo,{docs:u.top_docs})]}),f.jsxs("div",{className:"grow card",children:[f.jsx("h3",{children:"Lowest-scoring texts"}),f.jsx(Xo,{docs:u.bottom_docs})]})]}),f.jsxs("div",{className:"meta-footer",children:[f.jsx("strong",{children:"Reproducibility record"})," — model: ",f.jsx("code",{children:o.model})," (dim"," ",o.embedding_dim,") · items hash: ",f.jsx("code",{children:o.items_sha256_16})," · text column: ",f.jsx("code",{children:o.text_column})," · run:"," ",o.started_at," → ",o.finished_at," (",o.duration_seconds,"s) · numpy ",o.numpy,o.sentence_transformers&&` · sentence-transformers ${o.sentence_transformers}`,f.jsxs("div",{className:"mt small",children:["Construct reference: ",o.construct_reference||"—"]})]})]})}function At({k:e,v:t}){return f.jsxs("div",{className:"stat",children:[f.jsx("div",{className:"v",children:t}),f.jsx("div",{className:"k",children:e})]})}function Xo({docs:e}){return f.jsxs("table",{className:"docs",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{style:{width:60},children:"Score"}),f.jsx("th",{children:"Text"})]})}),f.jsx("tbody",{children:e.map(t=>f.jsxs("tr",{children:[f.jsx("td",{className:"score",children:t.score.toFixed(3)}),f.jsx("td",{children:t.text})]},t.row))})]})}function Id({histogram:e}){const{counts:t,edges:n}=e,r=640,l=180,i={top:10,right:10,bottom:26,left:34},u=r-i.left-i.right,o=l-i.top-i.bottom,s=Math.max(...t,1),c=u/t.length;return f.jsxs("svg",{viewBox:`0 0 ${r} ${l}`,style:{width:"100%",maxWidth:720},children:[[.25,.5,.75,1].map(h=>{const m=i.top+o-h*o;return f.jsxs("g",{children:[f.jsx("line",{x1:i.left,x2:r-i.right,y1:m,y2:m,stroke:"#eceef1"}),f.jsx("text",{x:i.left-6,y:m+4,fontSize:"10",fill:"#98a2b3",textAnchor:"end",children:Math.round(h*s)})]},h)}),t.map((h,m)=>{const v=h/s*o;return f.jsx("rect",{x:i.left+m*c+1.5,y:i.top+o-v,width:Math.max(1,c-3),height:v,rx:"2",fill:"#7a1f3d",opacity:"0.85",children:f.jsxs("title",{children:[n[m].toFixed(3)," – ",n[m+1].toFixed(3),": ",h]})},m)}),[0,Math.floor(t.length/2),t.length].map(h=>f.jsx("text",{x:i.left+h*c,y:l-8,fontSize:"10",fill:"#98a2b3",textAnchor:"middle",children:n[h].toFixed(2)},h)),f.jsx("line",{x1:i.left,x2:r-i.right,y1:i.top+o,y2:i.top+o,stroke:"#d0d5dd"})]})}function Ud({project:e}){var z;const[t,n]=R.useState([]),[r,l]=R.useState([]),[i,u]=R.useState([]),[o,s]=R.useState([]),[c,h]=R.useState(""),[m,v]=R.useState(""),[g,x]=R.useState(""),[k,F]=R.useState(""),[d,a]=R.useState(!1),[p,w]=R.useState(!1),[C,E]=R.useState(""),[j,P]=R.useState(!1),[A,T]=R.useState(null),ue=R.useRef(null),Ce=R.useCallback(()=>fe.listJobs(e.id).then(s).catch(()=>{}),[e.id]);R.useEffect(()=>{fe.listCorpora(e.id).then(n).catch(y=>E(y.message)),fe.listConstructs().then(l).catch(y=>E(y.message)),fe.models().then(y=>{u(y),y.length&&F(y[0].name)}).catch(y=>E(y.message)),Ce()},[e.id,Ce]);const nt=o.some(y=>y.status==="queued"||y.status==="running");R.useEffect(()=>{if(!nt)return;const y=setInterval(Ce,1200);return()=>clearInterval(y)},[nt,Ce]);const Ee=t.find(y=>y.id===c)||null,Ut=r.find(y=>y.id===g)||null;async function vn(y){var K;const D=(K=y.target.files)==null?void 0:K[0];if(D){a(!0),E("");try{const $e=await fe.uploadCorpus(e.id,D),Qe=await fe.listCorpora(e.id);n(Qe),h($e.id),v($e.suggested_text_column||$e.columns[0])}catch($e){E($e.message)}finally{a(!1),ue.current&&(ue.current.value="")}}}async function yn(){w(!0),E("");try{await fe.createJob({project_id:e.id,corpus_id:c,construct_id:g,text_column:m,model_name:k}),await Ce()}catch(y){E(y.message)}finally{w(!1)}}if(A)return f.jsx(Fd,{jobId:A,onBack:()=>{T(null),Ce()}});const _=c&&m&&g&&k&&!p;return f.jsxs(f.Fragment,{children:[C&&f.jsx("div",{className:"error-banner",onClick:()=>E(""),children:C}),f.jsxs("div",{className:"card",children:[f.jsxs("h3",{children:[f.jsx("span",{className:"step-badge",children:"1"}),"Corpus"]}),f.jsx("p",{className:"hint",children:"Upload a CSV or XLSX file, then choose the column containing the text to analyze."}),f.jsxs("div",{className:"row",children:[f.jsxs("div",{className:"grow",children:[f.jsxs("label",{className:"field",children:["Upload file",f.jsx("input",{ref:ue,type:"file",accept:".csv,.xlsx,.xls",onChange:vn,disabled:d})]}),d&&f.jsx("span",{className:"small muted",children:"Uploading…"})]}),f.jsx("div",{className:"grow",children:f.jsxs("label",{className:"field",children:["Corpus",f.jsxs("select",{value:c,onChange:y=>h(y.target.value),children:[f.jsx("option",{value:"",children:"— select —"}),t.map(y=>f.jsxs("option",{value:y.id,children:[y.filename," (",y.n_rows.toLocaleString()," rows)"]},y.id))]})]})}),f.jsx("div",{className:"grow",children:f.jsxs("label",{className:"field",children:["Text column",f.jsxs("select",{value:m,onChange:y=>v(y.target.value),disabled:!Ee,children:[f.jsx("option",{value:"",children:"— select —"}),Ee==null?void 0:Ee.columns.map(y=>f.jsxs("option",{value:y,children:[y,y===Ee.suggested_text_column?" (suggested)":""]},y))]})]})})]}),((z=Ee==null?void 0:Ee.parse_info)==null?void 0:z.note)&&f.jsxs("p",{className:"small muted",children:["⚠ ",Ee.parse_info.note]})]}),f.jsxs("div",{className:"card",children:[f.jsxs("h3",{children:[f.jsx("span",{className:"step-badge",children:"2"}),"Construct"]}),f.jsx("p",{className:"hint",children:"Pick a validated scale from the library, or define custom items. CCR scores each text by its similarity to these items."}),f.jsxs("div",{className:"row",children:[f.jsx("div",{className:"grow",children:f.jsxs("select",{value:g,onChange:y=>x(y.target.value),children:[f.jsx("option",{value:"",children:"— select construct —"}),r.map(y=>f.jsxs("option",{value:y.id,children:[y.name," (",y.items.length," items",y.is_seed?", library":", custom",")"]},y.id))]})}),f.jsx("button",{className:"ghost",onClick:()=>P(y=>!y),children:j?"Close":"+ Custom construct"})]}),Ut&&f.jsxs(f.Fragment,{children:[f.jsx("ul",{className:"construct-items",children:Ut.items.map((y,D)=>f.jsx("li",{children:y},D))}),Ut.reference&&f.jsxs("p",{className:"small muted mt",children:["Reference: ",Ut.reference]})]}),j&&f.jsx($d,{onCreated:async y=>{const D=await fe.listConstructs();l(D),x(y.id),P(!1)},onError:E})]}),f.jsxs("div",{className:"card",children:[f.jsxs("h3",{children:[f.jsx("span",{className:"step-badge",children:"3"}),"Model & run"]}),f.jsx("p",{className:"hint",children:"Embeddings run locally via sentence-transformers; the model is pinned and recorded in the run metadata for reproducibility."}),f.jsxs("div",{className:"row",children:[f.jsx("div",{className:"grow",children:f.jsx("select",{value:k,onChange:y=>F(y.target.value),children:i.map(y=>f.jsx("option",{value:y.name,children:y.label},y.name))})}),f.jsx("button",{className:"primary",disabled:!_,onClick:yn,children:p?"Starting…":"Run CCR analysis"})]})]}),o.length>0&&f.jsxs("div",{className:"card",children:[f.jsx("h3",{children:"Runs"}),f.jsxs("table",{className:"docs",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{children:"Started"}),f.jsx("th",{children:"Corpus"}),f.jsx("th",{children:"Construct"}),f.jsx("th",{style:{width:"24%"},children:"Status"}),f.jsx("th",{})]})}),f.jsx("tbody",{children:o.map(y=>f.jsxs("tr",{children:[f.jsx("td",{className:"muted",children:(y.started_at||y.created_at).replace("T"," ").slice(0,16)}),f.jsx("td",{children:y.corpus_filename}),f.jsx("td",{children:y.construct_name}),f.jsxs("td",{children:[y.status==="running"?f.jsx("div",{className:"progress-track",title:`${Math.round(y.progress*100)}%`,children:f.jsx("div",{className:"progress-fill",style:{width:`${Math.max(3,y.progress*100)}%`}})}):f.jsx("span",{className:`pill ${y.status}`,children:y.status}),y.status==="failed"&&f.jsx("div",{className:"small muted",title:y.error,children:y.error.split(` -`).pop()})]}),f.jsx("td",{children:y.status==="completed"&&f.jsx("button",{className:"linkish",onClick:()=>T(y.id),children:"View results"})})]},y.id))})]})]})]})}function $d({onCreated:e,onError:t}){const[n,r]=R.useState(""),[l,i]=R.useState(""),[u,o]=R.useState(""),[s,c]=R.useState(!1);async function h(m){m.preventDefault();const v=u.split(` -`).map(g=>g.trim()).filter(Boolean);if(!n.trim()||v.length===0){t("A custom construct needs a name and at least one item (one per line).");return}c(!0);try{const g=await fe.createConstruct({name:n.trim(),reference:l,items:v});e(g)}catch(g){t(g.message)}finally{c(!1)}}return f.jsxs("form",{onSubmit:h,className:"mt",children:[f.jsxs("div",{className:"row",children:[f.jsx("div",{className:"grow",children:f.jsxs("label",{className:"field",children:["Name",f.jsx("input",{type:"text",value:n,onChange:m=>r(m.target.value)})]})}),f.jsx("div",{className:"grow",children:f.jsxs("label",{className:"field",children:["Reference (publication, optional)",f.jsx("input",{type:"text",value:l,onChange:m=>i(m.target.value)})]})})]}),f.jsxs("label",{className:"field",children:["Scale items — one per line, verbatim from the validated instrument",f.jsx("textarea",{rows:5,value:u,onChange:m=>o(m.target.value)})]}),f.jsx("button",{className:"primary",type:"submit",disabled:s,children:s?"Saving…":"Save construct"})]})}function Ad(){const[e,t]=R.useState([]),[n,r]=R.useState(null),[l,i]=R.useState(!1),[u,o]=R.useState(""),[s,c]=R.useState(""),h=()=>fe.listProjects().then(t).catch(g=>c(g.message));R.useEffect(()=>{h()},[]);async function m(g){if(g.preventDefault(),!!u.trim())try{const x=await fe.createProject({name:u.trim()});o(""),i(!1),await h(),r(x.id)}catch(x){c(x.message)}}const v=e.find(g=>g.id===n)||null;return f.jsxs("div",{className:"app",children:[f.jsxs("header",{className:"header",children:[f.jsx("h1",{children:"CCR Platform"}),f.jsx("span",{className:"sub",children:"Contextualized Construct Representations · theory-driven psychological text analysis"})]}),f.jsxs("div",{className:"layout",children:[f.jsxs("aside",{className:"sidebar",children:[f.jsx("h2",{children:"Projects"}),e.map(g=>f.jsxs("button",{className:"project-item"+(g.id===n?" active":""),onClick:()=>r(g.id),children:[g.name,f.jsx("span",{className:"date",children:g.created_at.slice(0,10)})]},g.id)),l?f.jsxs("form",{onSubmit:m,className:"mt",children:[f.jsx("input",{type:"text",autoFocus:!0,placeholder:"Project name",value:u,onChange:g=>o(g.target.value)}),f.jsxs("div",{className:"row mt",children:[f.jsx("button",{className:"primary",type:"submit",children:"Create"}),f.jsx("button",{className:"ghost",type:"button",onClick:()=>i(!1),children:"Cancel"})]})]}):f.jsx("button",{className:"ghost mt",onClick:()=>i(!0),children:"+ New project"})]}),f.jsxs("main",{className:"main",children:[s&&f.jsx("div",{className:"error-banner",onClick:()=>c(""),children:s}),v?f.jsx(Ud,{project:v},v.id):f.jsxs("div",{className:"card",children:[f.jsx("h3",{children:"Welcome"}),f.jsx("p",{className:"hint",children:"Create or select a project, upload a corpus (CSV/XLSX), choose a validated construct, and run a CCR analysis. Results include per-item loadings, score distributions, and a reproducibility record for every run."}),f.jsx("p",{className:"small muted",children:"Self-contained by design: embeddings run on this server itself — no third-party AI APIs. Demo instance: storage is ephemeral and may reset; please don't upload sensitive or identifiable data."})]})]})]})]})}uc(document.getElementById("root")).render(f.jsx(Cc.StrictMode,{children:f.jsx(Ad,{})})); diff --git a/backend/static/assets/index-C356-0ZT.css b/backend/static/assets/index-C356-0ZT.css deleted file mode 100644 index 302dc9120060e7c2cf1a074a08789e3c3912dfc3..0000000000000000000000000000000000000000 --- a/backend/static/assets/index-C356-0ZT.css +++ /dev/null @@ -1 +0,0 @@ -:root{--maroon: #7a1f3d;--maroon-dark: #5e1730;--ink: #1d2129;--muted: #667085;--line: #e5e7eb;--bg: #f7f7f8;--card: #ffffff;--ok: #157f3d;--err: #b42318;--accent-soft: #f6ebef}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,sans-serif;color:var(--ink);background:var(--bg);font-size:14.5px;line-height:1.5}.app{display:flex;flex-direction:column;min-height:100vh}.header{background:var(--maroon);color:#fff;padding:14px 28px;display:flex;align-items:baseline;gap:14px}.header h1{font-size:17px;margin:0;font-weight:650;letter-spacing:.2px}.header .sub{font-size:12.5px;opacity:.85}.layout{display:flex;flex:1;min-height:0}.sidebar{width:250px;background:var(--card);border-right:1px solid var(--line);padding:18px 14px;flex-shrink:0}.sidebar h2{font-size:11.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);margin:0 0 10px 4px}.project-item{display:block;width:100%;text-align:left;padding:9px 12px;margin-bottom:4px;border:1px solid transparent;border-radius:8px;background:none;cursor:pointer;font:inherit;color:var(--ink)}.project-item:hover{background:var(--bg)}.project-item.active{background:var(--accent-soft);border-color:var(--maroon);font-weight:600}.project-item .date{display:block;font-size:11.5px;color:var(--muted);font-weight:400}.main{flex:1;padding:22px 28px;overflow-y:auto;min-width:0}.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:16px}.card h3{margin:0 0 4px;font-size:15px}.card .hint{color:var(--muted);font-size:12.5px;margin:0 0 12px}.step-badge{display:inline-flex;align-items:center;justify-content:center;width:21px;height:21px;border-radius:50%;background:var(--maroon);color:#fff;font-size:12px;font-weight:700;margin-right:8px;vertical-align:-3px}button.primary{background:var(--maroon);color:#fff;border:none;padding:9px 18px;border-radius:8px;font:inherit;font-weight:600;cursor:pointer}button.primary:hover{background:var(--maroon-dark)}button.primary:disabled{background:#c9ccd1;cursor:not-allowed}button.ghost{background:none;border:1px solid var(--line);color:var(--ink);padding:8px 14px;border-radius:8px;font:inherit;cursor:pointer}button.ghost:hover{border-color:var(--maroon);color:var(--maroon)}button.linkish{background:none;border:none;color:var(--maroon);font:inherit;cursor:pointer;padding:0;text-decoration:underline}input[type=text],textarea,select{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;font:inherit;background:#fff;color:var(--ink)}textarea{resize:vertical}label.field{display:block;margin-bottom:10px;font-size:13px;font-weight:600}label.field>*{margin-top:4px;font-weight:400}.row{display:flex;gap:14px;flex-wrap:wrap}.row>.grow{flex:1;min-width:220px}.pill{display:inline-block;padding:2px 10px;border-radius:999px;font-size:11.5px;font-weight:600}.pill.completed{background:#e6f4ea;color:var(--ok)}.pill.running{background:#fff3e0;color:#b45309}.pill.queued{background:#eef2f7;color:var(--muted)}.pill.failed{background:#fdecea;color:var(--err)}.progress-track{background:var(--line);border-radius:999px;height:7px;overflow:hidden}.progress-fill{background:var(--maroon);height:100%;transition:width .4s ease}.warnings{background:#fff8e6;border:1px solid #f2dfa8;color:#7a5b00;border-radius:8px;padding:10px 14px}.error-banner{background:#fdecea;color:var(--err);border:1px solid #f5c6c0;padding:10px 14px;border-radius:8px;margin-bottom:14px;font-size:13px}table.docs{width:100%;border-collapse:collapse;font-size:13px}table.docs th{text-align:left;color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.5px;padding:6px 8px;border-bottom:1px solid var(--line)}table.docs td{padding:7px 8px;border-bottom:1px solid var(--bg);vertical-align:top}table.docs td.score{font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap}.stat-grid{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:4px}.stat{flex:1;min-width:110px;background:var(--bg);border-radius:8px;padding:10px 14px}.stat .v{font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}.stat .k{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}.item-bar-row{display:flex;align-items:center;gap:10px;margin-bottom:7px}.item-bar-label{flex:1;font-size:12.5px;min-width:0}.item-bar-track{flex:1.2;background:var(--bg);border-radius:999px;height:10px}.item-bar-fill{background:var(--maroon);opacity:.85;height:100%;border-radius:999px}.item-bar-val{width:52px;text-align:right;font-variant-numeric:tabular-nums;font-size:12.5px;font-weight:600}.construct-items{margin:8px 0 0;padding-left:20px;color:var(--muted);font-size:12.5px}.construct-items li{margin-bottom:2px}.meta-footer{font-size:12px;color:var(--muted);background:var(--bg);border-radius:8px;padding:10px 14px;margin-top:14px;font-variant-numeric:tabular-nums}.meta-footer code{font-size:11.5px}.muted{color:var(--muted)}.small{font-size:12.5px}.mt{margin-top:12px} diff --git a/backend/static/assets/index-CN_FzJfm.css b/backend/static/assets/index-CN_FzJfm.css new file mode 100644 index 0000000000000000000000000000000000000000..3c82ceaf2767ed939b88c546bd58b876ee8acfa8 --- /dev/null +++ b/backend/static/assets/index-CN_FzJfm.css @@ -0,0 +1 @@ +:root{--maroon: #7a1f3d;--maroon-dark: #5e1730;--ink: #1d2129;--muted: #667085;--line: #e5e7eb;--bg: #f7f7f8;--card: #ffffff;--ok: #157f3d;--err: #b42318;--accent-soft: #f6ebef;--control-height: 40px}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,sans-serif;color:var(--ink);background:var(--bg);font-size:14.5px;line-height:1.5}.app{display:flex;flex-direction:column;min-height:100vh}.header{background:var(--maroon);color:#fff;padding:14px 28px;display:flex;align-items:baseline;flex-wrap:wrap;gap:14px}.header h1{flex:0 0 auto;font-size:17px;margin:0;font-weight:650;letter-spacing:.2px;white-space:nowrap}.header .sub{flex:1 1 280px;min-width:0;font-size:12.5px;opacity:.85}.layout{display:flex;flex:1;min-width:0;min-height:0}.sidebar{width:250px;background:var(--card);border-right:1px solid var(--line);padding:18px 14px;flex-shrink:0;display:flex;flex-direction:column;min-height:0}.sidebar h2{font-size:11.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);margin:0 0 10px 4px;display:flex;align-items:center;gap:8px}.sidebar h2 .count{background:var(--bg);border:1px solid var(--line);border-radius:999px;padding:0 8px;font-size:10.5px;letter-spacing:0;color:var(--muted)}.sidebar-filter{width:100%;padding:7px 10px;margin-bottom:10px;border:1px solid var(--line);border-radius:8px;font:inherit;font-size:13px;background:#fff;color:var(--ink)}.sidebar-filter:focus{outline:none;border-color:var(--maroon)}.project-list{flex:1;min-height:0;overflow-y:auto;margin:0 -4px;padding:0 4px 4px}.group-label{font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin:10px 4px 5px}div:first-child>.group-label{margin-top:2px}.project-item{display:block;width:100%;text-align:left;padding:9px 12px;margin-bottom:5px;border:1px solid transparent;border-radius:8px;background:none;cursor:pointer;font:inherit;color:var(--ink)}.project-item:hover{background:var(--bg)}.project-item.active{background:var(--accent-soft);border-color:var(--maroon);font-weight:600}.project-item .project-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.project-item .date{display:block;font-size:11.5px;color:var(--muted);font-weight:400}.project-create{margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}.project-create>button{width:100%}.main{flex:1;padding:22px 28px;overflow-y:auto;min-width:0}.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:18px 20px;margin-bottom:16px}.card h3{margin:0 0 4px;font-size:15px}.card .hint{color:var(--muted);font-size:12.5px;margin:0 0 12px}.step-badge{display:inline-flex;align-items:center;justify-content:center;width:21px;height:21px;border-radius:50%;background:var(--maroon);color:#fff;font-size:12px;font-weight:700;margin-right:8px;vertical-align:-3px}button{white-space:nowrap}button.primary{background:var(--maroon);color:#fff;border:none;min-height:var(--control-height);display:inline-flex;align-items:center;justify-content:center;padding:0 18px;border-radius:8px;font:inherit;font-weight:600;cursor:pointer;line-height:1.2}button.primary:hover{background:var(--maroon-dark)}button.primary:disabled{background:#c9ccd1;cursor:not-allowed}a.google-btn{background:var(--maroon);color:#fff;border:none;min-height:var(--control-height);display:flex;align-items:center;justify-content:center;padding:0 18px;border-radius:8px;font:inherit;font-weight:600;cursor:pointer;line-height:1.2;text-decoration:none;width:100%;box-sizing:border-box}a.google-btn:hover{background:var(--maroon-dark)}button.ghost{background:none;border:1px solid var(--line);color:var(--ink);min-height:var(--control-height);display:inline-flex;align-items:center;justify-content:center;padding:0 14px;border-radius:8px;font:inherit;cursor:pointer;line-height:1.2}button.ghost:hover{border-color:var(--maroon);color:var(--maroon)}button.linkish{background:none;border:none;color:var(--maroon);font:inherit;cursor:pointer;padding:0;text-decoration:underline}input[type=text],input[type=email],input[type=password],textarea,select{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;font:inherit;background:#fff;color:var(--ink)}input[type=text],input[type=email],input[type=password],select{height:var(--control-height)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid rgba(122,31,61,.38);outline-offset:2px}input[type=file]{display:block;max-width:100%;margin-top:6px;font-size:13px;color:var(--muted)}input[type=file]::file-selector-button{background:#fff;border:1px solid var(--line);color:var(--ink);padding:7px 14px;border-radius:8px;font:inherit;font-size:13px;cursor:pointer;margin-right:10px}input[type=file]::file-selector-button:hover{border-color:var(--maroon);color:var(--maroon)}.row>button{align-self:flex-end;margin-bottom:1px}textarea{resize:vertical}label.field{display:block;margin-bottom:10px;font-size:13px;font-weight:600}label.field>*{margin-top:4px;font-weight:400}.field-hint{margin-top:0;font-weight:400;color:var(--muted);font-size:12px}.row{display:flex;gap:14px;flex-wrap:wrap}.row>*{min-width:0}.row>.grow{flex:1;min-width:min(220px,100%)}.language-control{min-width:170px}.model-control{min-width:260px}.run-settings{display:grid;grid-template-columns:minmax(160px,230px) minmax(320px,720px) max-content;justify-content:start;gap:14px;align-items:end}.run-settings .field{margin-bottom:0}.run-button{min-width:180px;height:var(--control-height)}.construct-row{display:grid;grid-template-columns:minmax(320px,1120px) max-content;justify-content:start;align-items:end;gap:14px}.results-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:14px;flex-wrap:wrap}.result-actions{justify-content:flex-end}.result-actions a{display:inline-flex;text-decoration:none}.pill{display:inline-block;padding:2px 10px;border-radius:999px;font-size:11.5px;font-weight:600}.pill.completed{background:#e6f4ea;color:var(--ok)}.pill.running{background:#fff3e0;color:#b45309}.pill.queued{background:#eef2f7;color:var(--muted)}.pill.failed{background:#fdecea;color:var(--err)}.progress-track{background:var(--line);border-radius:999px;height:7px;overflow:hidden}.progress-fill{background:var(--maroon);height:100%;transition:width .4s ease}.warnings{background:#fff8e6;border:1px solid #f2dfa8;color:#7a5b00;border-radius:8px;padding:10px 14px}.error-banner{background:#fdecea;color:var(--err);border:1px solid #f5c6c0;padding:10px 14px;border-radius:8px;margin-bottom:14px;font-size:13px}.table-wrap{width:100%;overflow-x:auto}table.docs{width:100%;border-collapse:collapse;font-size:13px}table.docs th{text-align:left;color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.5px;padding:6px 8px;border-bottom:1px solid var(--line)}table.docs td{padding:7px 8px;border-bottom:1px solid var(--bg);vertical-align:top;overflow-wrap:anywhere}table.docs td.score{font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap}.stat-grid{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:4px}.stat{flex:1;min-width:110px;background:var(--bg);border-radius:8px;padding:10px 14px}.stat .v{font-size:20px;font-weight:700;font-variant-numeric:tabular-nums}.stat .k{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}.item-bar-row{display:flex;align-items:center;gap:10px;margin-bottom:7px}.item-bar-label{flex:1;font-size:12.5px;min-width:0;overflow-wrap:anywhere}.item-bar-track{flex:1.2;background:var(--bg);border-radius:999px;height:10px}.item-bar-fill{background:var(--maroon);opacity:.85;height:100%;border-radius:999px}.item-bar-val{width:52px;text-align:right;font-variant-numeric:tabular-nums;font-size:12.5px;font-weight:600}.construct-items{margin:8px 0 0;padding-left:20px;color:var(--muted);font-size:12.5px}.construct-items li{margin-bottom:2px}.meta-footer{font-size:12px;color:var(--muted);background:var(--bg);border-radius:8px;padding:10px 14px;margin-top:14px;font-variant-numeric:tabular-nums}.meta-footer code{font-size:11.5px}.muted{color:var(--muted)}.small{font-size:12.5px}.mt{margin-top:12px}.header-auth{margin-left:auto;display:flex;align-items:center;gap:10px;color:#fff}.header-btn{background:#ffffff1f;color:#fff;border:1px solid rgba(255,255,255,.45);padding:5px 14px;border-radius:7px;font:inherit;font-size:13px;cursor:pointer}.header-btn:hover{background:#ffffff38}.project-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}.project-title{font-size:17px;font-weight:650;margin-right:10px}button.danger{color:var(--err);border-color:#f0c4be}button.danger:hover{color:var(--err);border-color:var(--err)}button.danger-solid{background:var(--err)}button.danger-solid:hover{background:#93261b}button.danger-solid:disabled{background:#c9ccd1}.picker{position:relative}.picker-display{width:100%;display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit;color:var(--ink);cursor:pointer;text-align:left}.picker-display:hover{border-color:var(--maroon)}.picker-display .picker-caret{margin-left:auto;color:var(--muted);font-size:11px}.picker-search{width:100%;padding:8px 12px;border:1px solid var(--maroon);border-radius:8px;font:inherit;background:#fff}.picker-search:focus{outline:none;box-shadow:0 0 0 3px #7a1f3d1f}.picker-panel{position:absolute;top:calc(100% + 6px);left:0;z-index:50;width:100%;max-width:640px;background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 14px 40px #0000002e;max-height:340px;overflow-y:auto;padding:4px 0 6px}.picker-group{font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);padding:8px 12px 3px;position:sticky;top:0;background:var(--card)}.picker-option{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:6px 12px;cursor:pointer;font-size:13.5px}.picker-option:hover,.picker-option.active{background:var(--accent-soft)}.picker-option.selected .picker-name{font-weight:650;color:var(--maroon)}.picker-name{min-width:0}.picker-meta{flex-shrink:0;font-size:11.5px;color:var(--muted);white-space:nowrap}.picker-empty{padding:12px;margin:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#14161a73;display:flex;align-items:center;justify-content:center;z-index:40;padding:16px}.modal{background:var(--card);border-radius:12px;padding:26px 28px 24px;width:100%;max-width:460px;box-shadow:0 12px 40px #0000002e}.modal h3{margin:0 0 8px;font-size:18px}.modal .hint{color:var(--muted);font-size:13px;line-height:1.5;margin:0 0 4px}.modal form.mt{margin-top:18px}.modal label.field{margin-bottom:16px}.modal label.field:last-of-type{margin-bottom:20px}.modal .row{gap:10px}.modal .row>.primary{flex:1}.modal p.small.muted.mt{margin-top:18px;padding-top:16px;border-top:1px solid var(--line);font-size:12.5px;line-height:1.6}@media (max-width: 820px){body{font-size:14px}.header{padding:12px 16px;align-items:flex-start;gap:2px 12px}.header h1{font-size:16px}.header .sub{flex:1 1 210px;font-size:12px;line-height:1.35}.layout{display:block}.sidebar{width:100%;border-right:0;border-bottom:1px solid var(--line);padding:14px}.project-list{max-height:220px;margin-right:0;flex:none}.project-create{border-top:0}.main{width:100%;padding:16px 14px 28px;overflow:visible}.card{padding:16px;margin-bottom:14px}.row,.run-settings,.construct-row,.results-toolbar,.result-actions{gap:10px}.row,.results-toolbar,.result-actions{flex-direction:column;align-items:stretch}.run-settings,.construct-row{grid-template-columns:1fr}.row>.grow,.construct-row>.grow,.language-control,.model-control{width:100%;min-width:0}.main .row>button,.main .row>a,.main .row>a>button,.results-toolbar>button{align-self:stretch;margin-bottom:0;width:100%}.run-button{width:100%;min-width:0}.stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.stat{min-width:0}.item-bar-row{display:grid;grid-template-columns:minmax(0,1fr) 56px;gap:6px 10px}.item-bar-track{grid-column:1 / -1;width:100%}.item-bar-val{width:auto}table.docs{min-width:520px}}@media (max-width: 460px){.header .sub{flex-basis:100%}.stat-grid{grid-template-columns:1fr}} diff --git a/backend/static/assets/index-DrFcy6bH.js b/backend/static/assets/index-DrFcy6bH.js new file mode 100644 index 0000000000000000000000000000000000000000..76df72f83dc50062db628735889333b2d74ff9a5 --- /dev/null +++ b/backend/static/assets/index-DrFcy6bH.js @@ -0,0 +1,43 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function jc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var su={exports:{}},hl={},uu={exports:{}},F={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var fr=Symbol.for("react.element"),Ec=Symbol.for("react.portal"),Pc=Symbol.for("react.fragment"),Tc=Symbol.for("react.strict_mode"),zc=Symbol.for("react.profiler"),Lc=Symbol.for("react.provider"),Rc=Symbol.for("react.context"),Mc=Symbol.for("react.forward_ref"),Dc=Symbol.for("react.suspense"),Fc=Symbol.for("react.memo"),Oc=Symbol.for("react.lazy"),Zo=Symbol.iterator;function Ic(e){return e===null||typeof e!="object"?null:(e=Zo&&e[Zo]||e["@@iterator"],typeof e=="function"?e:null)}var au={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},cu=Object.assign,fu={};function kn(e,t,n){this.props=e,this.context=t,this.refs=fu,this.updater=n||au}kn.prototype.isReactComponent={};kn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};kn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function du(){}du.prototype=kn.prototype;function Gi(e,t,n){this.props=e,this.context=t,this.refs=fu,this.updater=n||au}var Zi=Gi.prototype=new du;Zi.constructor=Gi;cu(Zi,kn.prototype);Zi.isPureReactComponent=!0;var Jo=Array.isArray,pu=Object.prototype.hasOwnProperty,Ji={current:null},mu={key:!0,ref:!0,__self:!0,__source:!0};function hu(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)pu.call(t,r)&&!mu.hasOwnProperty(r)&&(l[r]=t[r]);var s=arguments.length-2;if(s===1)l.children=n;else if(1>>1,z=_[A];if(0>>1;Al(_n,M))xel(Tt,_n)?(_[A]=Tt,_[xe]=M,A=xe):(_[A]=_n,_[q]=M,A=q);else if(xel(Tt,M))_[A]=Tt,_[xe]=M,A=xe;else break e}}return R}function l(_,R){var M=_.sortIndex-R.sortIndex;return M!==0?M:_.id-R.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var a=[],d=[],h=1,v=null,m=3,x=!1,w=!1,N=!1,D=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function f(_){for(var R=n(d);R!==null;){if(R.callback===null)r(d);else if(R.startTime<=_)r(d),R.sortIndex=R.expirationTime,t(a,R);else break;R=n(d)}}function g(_){if(N=!1,f(_),!w)if(n(a)!==null)w=!0,ut(S);else{var R=n(d);R!==null&&Qe(g,R.startTime-_)}}function S(_,R){w=!1,N&&(N=!1,p(P),P=-1),x=!0;var M=m;try{for(f(R),v=n(a);v!==null&&(!(v.expirationTime>R)||_&&!O());){var A=v.callback;if(typeof A=="function"){v.callback=null,m=v.priorityLevel;var z=A(v.expirationTime<=R);R=e.unstable_now(),typeof z=="function"?v.callback=z:v===n(a)&&r(a),f(R)}else r(a);v=n(a)}if(v!==null)var ee=!0;else{var q=n(d);q!==null&&Qe(g,q.startTime-R),ee=!1}return ee}finally{v=null,m=M,x=!1}}var C=!1,j=null,P=-1,I=5,L=-1;function O(){return!(e.unstable_now()-L_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):I=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return m},e.unstable_getFirstCallbackNode=function(){return n(a)},e.unstable_next=function(_){switch(m){case 1:case 2:case 3:var R=3;break;default:R=m}var M=m;m=R;try{return _()}finally{m=M}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,R){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var M=m;m=_;try{return R()}finally{m=M}},e.unstable_scheduleCallback=function(_,R,M){var A=e.unstable_now();switch(typeof M=="object"&&M!==null?(M=M.delay,M=typeof M=="number"&&0A?(_.sortIndex=M,t(d,_),n(a)===null&&_===n(d)&&(N?(p(P),P=-1):N=!0,Qe(g,M-A))):(_.sortIndex=z,t(a,_),w||x||(w=!0,ut(S))),_},e.unstable_shouldYield=O,e.unstable_wrapCallback=function(_){var R=m;return function(){var M=m;m=R;try{return _.apply(this,arguments)}finally{m=M}}}})(wu);xu.exports=wu;var Gc=xu.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Zc=T,Pe=Gc;function y(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ri=Object.prototype.hasOwnProperty,Jc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,bo={},es={};function qc(e){return ri.call(es,e)?!0:ri.call(bo,e)?!1:Jc.test(e)?es[e]=!0:(bo[e]=!0,!1)}function bc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function ef(e,t,n,r){if(t===null||typeof t>"u"||bc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ye(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var ae={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ae[e]=new ye(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ae[t]=new ye(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ae[e]=new ye(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ae[e]=new ye(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ae[e]=new ye(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ae[e]=new ye(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ae[e]=new ye(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ae[e]=new ye(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ae[e]=new ye(e,5,!1,e.toLowerCase(),null,!1,!1)});var bi=/[\-:]([a-z])/g;function eo(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(bi,eo);ae[t]=new ye(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(bi,eo);ae[t]=new ye(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(bi,eo);ae[t]=new ye(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ae[e]=new ye(e,1,!1,e.toLowerCase(),null,!1,!1)});ae.xlinkHref=new ye("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ae[e]=new ye(e,1,!1,e.toLowerCase(),null,!0,!0)});function to(e,t,n,r){var l=ae.hasOwnProperty(t)?ae[t]:null;(l!==null?l.type!==0:r||!(2s||l[o]!==i[s]){var a=` +`+l[o].replace(" at new "," at ");return e.displayName&&a.includes("")&&(a=a.replace("",e.displayName)),a}while(1<=o&&0<=s);break}}}finally{Ml=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Fn(e):""}function tf(e){switch(e.tag){case 5:return Fn(e.type);case 16:return Fn("Lazy");case 13:return Fn("Suspense");case 19:return Fn("SuspenseList");case 0:case 2:case 15:return e=Dl(e.type,!1),e;case 11:return e=Dl(e.type.render,!1),e;case 1:return e=Dl(e.type,!0),e;default:return""}}function si(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Jt:return"Fragment";case Zt:return"Portal";case li:return"Profiler";case no:return"StrictMode";case ii:return"Suspense";case oi:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Nu:return(e.displayName||"Context")+".Consumer";case ku:return(e._context.displayName||"Context")+".Provider";case ro:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case lo:return t=e.displayName||null,t!==null?t:si(e.type)||"Memo";case ct:t=e._payload,e=e._init;try{return si(e(t))}catch{}}return null}function nf(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return si(t);case 8:return t===no?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Ct(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function _u(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function rf(e){var t=_u(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function yr(e){e._valueTracker||(e._valueTracker=rf(e))}function ju(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=_u(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Qr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ui(e,t){var n=t.checked;return G({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ns(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=Ct(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Eu(e,t){t=t.checked,t!=null&&to(e,"checked",t,!1)}function ai(e,t){Eu(e,t);var n=Ct(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ci(e,t.type,n):t.hasOwnProperty("defaultValue")&&ci(e,t.type,Ct(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function rs(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ci(e,t,n){(t!=="number"||Qr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var On=Array.isArray;function an(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=xr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Gn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var $n={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},lf=["Webkit","ms","Moz","O"];Object.keys($n).forEach(function(e){lf.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),$n[t]=$n[e]})});function Lu(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||$n.hasOwnProperty(e)&&$n[e]?(""+t).trim():t+"px"}function Ru(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Lu(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var of=G({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function pi(e,t){if(t){if(of[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(y(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(y(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(y(61))}if(t.style!=null&&typeof t.style!="object")throw Error(y(62))}}function mi(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var hi=null;function io(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var vi=null,cn=null,fn=null;function os(e){if(e=mr(e)){if(typeof vi!="function")throw Error(y(280));var t=e.stateNode;t&&(t=wl(t),vi(e.stateNode,e.type,t))}}function Mu(e){cn?fn?fn.push(e):fn=[e]:cn=e}function Du(){if(cn){var e=cn,t=fn;if(fn=cn=null,os(e),t)for(e=0;e>>=0,e===0?32:31-(gf(e)/yf|0)|0}var wr=64,Sr=4194304;function In(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Gr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~l;s!==0?r=In(s):(i&=o,i!==0&&(r=In(i)))}else o=n&~l,o!==0?r=In(o):i!==0&&(r=In(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function dr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Be(t),e[t]=n}function kf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Vn),hs=" ",vs=!1;function ea(e,t){switch(e){case"keyup":return Zf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function ta(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var qt=!1;function qf(e,t){switch(e){case"compositionend":return ta(t);case"keypress":return t.which!==32?null:(vs=!0,hs);case"textInput":return e=t.data,e===hs&&vs?null:e;default:return null}}function bf(e,t){if(qt)return e==="compositionend"||!mo&&ea(e,t)?(e=qu(),Or=co=mt=null,qt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=ws(n)}}function ia(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?ia(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function oa(){for(var e=window,t=Qr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Qr(e.document)}return t}function ho(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function ud(e){var t=oa(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&ia(n.ownerDocument.documentElement,n)){if(r!==null&&ho(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=Ss(n,i);var o=Ss(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,bt=null,ki=null,Hn=null,Ni=!1;function ks(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ni||bt==null||bt!==Qr(r)||(r=bt,"selectionStart"in r&&ho(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Hn&&tr(Hn,r)||(Hn=r,r=qr(ki,"onSelect"),0nn||(e.current=Ti[nn],Ti[nn]=null,nn--)}function V(e,t){nn++,Ti[nn]=e.current,e.current=t}var _t={},me=Et(_t),ke=Et(!1),$t=_t;function vn(e,t){var n=e.type.contextTypes;if(!n)return _t;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Ne(e){return e=e.childContextTypes,e!=null}function el(){W(ke),W(me)}function Ts(e,t,n){if(me.current!==_t)throw Error(y(168));V(me,t),V(ke,n)}function ha(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(y(108,nf(e)||"Unknown",l));return G({},n,r)}function tl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||_t,$t=me.current,V(me,e),V(ke,ke.current),!0}function zs(e,t,n){var r=e.stateNode;if(!r)throw Error(y(169));n?(e=ha(e,t,$t),r.__reactInternalMemoizedMergedChildContext=e,W(ke),W(me),V(me,e)):W(ke),V(ke,n)}var be=null,Sl=!1,Yl=!1;function va(e){be===null?be=[e]:be.push(e)}function wd(e){Sl=!0,va(e)}function Pt(){if(!Yl&&be!==null){Yl=!0;var e=0,t=$;try{var n=be;for($=1;e>=o,l-=o,et=1<<32-Be(t)+l|n<P?(I=j,j=null):I=j.sibling;var L=m(p,j,f[P],g);if(L===null){j===null&&(j=I);break}e&&j&&L.alternate===null&&t(p,j),c=i(L,c,P),C===null?S=L:C.sibling=L,C=L,j=I}if(P===f.length)return n(p,j),K&&Rt(p,P),S;if(j===null){for(;PP?(I=j,j=null):I=j.sibling;var O=m(p,j,L.value,g);if(O===null){j===null&&(j=I);break}e&&j&&O.alternate===null&&t(p,j),c=i(O,c,P),C===null?S=O:C.sibling=O,C=O,j=I}if(L.done)return n(p,j),K&&Rt(p,P),S;if(j===null){for(;!L.done;P++,L=f.next())L=v(p,L.value,g),L!==null&&(c=i(L,c,P),C===null?S=L:C.sibling=L,C=L);return K&&Rt(p,P),S}for(j=r(p,j);!L.done;P++,L=f.next())L=x(j,p,P,L.value,g),L!==null&&(e&&L.alternate!==null&&j.delete(L.key===null?P:L.key),c=i(L,c,P),C===null?S=L:C.sibling=L,C=L);return e&&j.forEach(function(B){return t(p,B)}),K&&Rt(p,P),S}function D(p,c,f,g){if(typeof f=="object"&&f!==null&&f.type===Jt&&f.key===null&&(f=f.props.children),typeof f=="object"&&f!==null){switch(f.$$typeof){case gr:e:{for(var S=f.key,C=c;C!==null;){if(C.key===S){if(S=f.type,S===Jt){if(C.tag===7){n(p,C.sibling),c=l(C,f.props.children),c.return=p,p=c;break e}}else if(C.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===ct&&Ms(S)===C.type){n(p,C.sibling),c=l(C,f.props),c.ref=Rn(p,C,f),c.return=p,p=c;break e}n(p,C);break}else t(p,C);C=C.sibling}f.type===Jt?(c=Ut(f.props.children,p.mode,g,f.key),c.return=p,p=c):(g=Wr(f.type,f.key,f.props,null,p.mode,g),g.ref=Rn(p,c,f),g.return=p,p=g)}return o(p);case Zt:e:{for(C=f.key;c!==null;){if(c.key===C)if(c.tag===4&&c.stateNode.containerInfo===f.containerInfo&&c.stateNode.implementation===f.implementation){n(p,c.sibling),c=l(c,f.children||[]),c.return=p,p=c;break e}else{n(p,c);break}else t(p,c);c=c.sibling}c=ni(f,p.mode,g),c.return=p,p=c}return o(p);case ct:return C=f._init,D(p,c,C(f._payload),g)}if(On(f))return w(p,c,f,g);if(En(f))return N(p,c,f,g);Pr(p,f)}return typeof f=="string"&&f!==""||typeof f=="number"?(f=""+f,c!==null&&c.tag===6?(n(p,c.sibling),c=l(c,f),c.return=p,p=c):(n(p,c),c=ti(f,p.mode,g),c.return=p,p=c),o(p)):n(p,c)}return D}var yn=wa(!0),Sa=wa(!1),ll=Et(null),il=null,on=null,xo=null;function wo(){xo=on=il=null}function So(e){var t=ll.current;W(ll),e._currentValue=t}function Ri(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function pn(e,t){il=e,xo=on=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(Se=!0),e.firstContext=null)}function Oe(e){var t=e._currentValue;if(xo!==e)if(e={context:e,memoizedValue:t,next:null},on===null){if(il===null)throw Error(y(308));on=e,il.dependencies={lanes:0,firstContext:e}}else on=on.next=e;return t}var Ft=null;function ko(e){Ft===null?Ft=[e]:Ft.push(e)}function ka(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,ko(t)):(n.next=l.next,l.next=n),t.interleaved=n,it(e,r)}function it(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var ft=!1;function No(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Na(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function nt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function wt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,U&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,it(e,n)}return l=r.interleaved,l===null?(t.next=t,ko(r)):(t.next=l.next,l.next=t),r.interleaved=t,it(e,n)}function Ur(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,so(e,n)}}function Ds(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=o:i=i.next=o,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function ol(e,t,n,r){var l=e.updateQueue;ft=!1;var i=l.firstBaseUpdate,o=l.lastBaseUpdate,s=l.shared.pending;if(s!==null){l.shared.pending=null;var a=s,d=a.next;a.next=null,o===null?i=d:o.next=d,o=a;var h=e.alternate;h!==null&&(h=h.updateQueue,s=h.lastBaseUpdate,s!==o&&(s===null?h.firstBaseUpdate=d:s.next=d,h.lastBaseUpdate=a))}if(i!==null){var v=l.baseState;o=0,h=d=a=null,s=i;do{var m=s.lane,x=s.eventTime;if((r&m)===m){h!==null&&(h=h.next={eventTime:x,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});e:{var w=e,N=s;switch(m=t,x=n,N.tag){case 1:if(w=N.payload,typeof w=="function"){v=w.call(x,v,m);break e}v=w;break e;case 3:w.flags=w.flags&-65537|128;case 0:if(w=N.payload,m=typeof w=="function"?w.call(x,v,m):w,m==null)break e;v=G({},v,m);break e;case 2:ft=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[s]:m.push(s))}else x={eventTime:x,lane:m,tag:s.tag,payload:s.payload,callback:s.callback,next:null},h===null?(d=h=x,a=v):h=h.next=x,o|=m;if(s=s.next,s===null){if(s=l.shared.pending,s===null)break;m=s,s=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(h===null&&(a=v),l.baseState=a,l.firstBaseUpdate=d,l.lastBaseUpdate=h,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);Bt|=o,e.lanes=o,e.memoizedState=v}}function Fs(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Zl.transition;Zl.transition={};try{e(!1),t()}finally{$=n,Zl.transition=r}}function Aa(){return Ie().memoizedState}function Cd(e,t,n){var r=kt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Va(e))Ba(t,n);else if(n=ka(e,t,n,r),n!==null){var l=ve();He(n,e,r,l),Ha(n,t,r)}}function _d(e,t,n){var r=kt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Va(e))Ba(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var o=t.lastRenderedState,s=i(o,n);if(l.hasEagerState=!0,l.eagerState=s,We(s,o)){var a=t.interleaved;a===null?(l.next=l,ko(t)):(l.next=a.next,a.next=l),t.interleaved=l;return}}catch{}finally{}n=ka(e,t,l,r),n!==null&&(l=ve(),He(n,e,r,l),Ha(n,t,r))}}function Va(e){var t=e.alternate;return e===Y||t!==null&&t===Y}function Ba(e,t){Wn=ul=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ha(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,so(e,n)}}var al={readContext:Oe,useCallback:fe,useContext:fe,useEffect:fe,useImperativeHandle:fe,useInsertionEffect:fe,useLayoutEffect:fe,useMemo:fe,useReducer:fe,useRef:fe,useState:fe,useDebugValue:fe,useDeferredValue:fe,useTransition:fe,useMutableSource:fe,useSyncExternalStore:fe,useId:fe,unstable_isNewReconciler:!1},jd={readContext:Oe,useCallback:function(e,t){return Xe().memoizedState=[e,t===void 0?null:t],e},useContext:Oe,useEffect:Is,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Ar(4194308,4,Fa.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ar(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ar(4,2,e,t)},useMemo:function(e,t){var n=Xe();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Xe();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Cd.bind(null,Y,e),[r.memoizedState,e]},useRef:function(e){var t=Xe();return e={current:e},t.memoizedState=e},useState:Os,useDebugValue:Lo,useDeferredValue:function(e){return Xe().memoizedState=e},useTransition:function(){var e=Os(!1),t=e[0];return e=Nd.bind(null,e[1]),Xe().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=Y,l=Xe();if(K){if(n===void 0)throw Error(y(407));n=n()}else{if(n=t(),ie===null)throw Error(y(349));Vt&30||Ea(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,Is(Ta.bind(null,r,i,e),[e]),r.flags|=2048,ar(9,Pa.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Xe(),t=ie.identifierPrefix;if(K){var n=tt,r=et;n=(r&~(1<<32-Be(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=sr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Ye]=t,e[lr]=r,ba(e,t,!1,!1),t.stateNode=e;e:{switch(o=mi(n,r),n){case"dialog":H("cancel",e),H("close",e),l=r;break;case"iframe":case"object":case"embed":H("load",e),l=r;break;case"video":case"audio":for(l=0;lSn&&(t.flags|=128,r=!0,Mn(i,!1),t.lanes=4194304)}else{if(!r)if(e=sl(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Mn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!o.alternate&&!K)return de(t),null}else 2*J()-i.renderingStartTime>Sn&&n!==1073741824&&(t.flags|=128,r=!0,Mn(i,!1),t.lanes=4194304);i.isBackwards?(o.sibling=t.child,t.child=o):(n=i.last,n!==null?n.sibling=o:t.child=o,i.last=o)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=J(),t.sibling=null,n=X.current,V(X,r?n&1|2:n&1),t):(de(t),null);case 22:case 23:return Io(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?_e&1073741824&&(de(t),t.subtreeFlags&6&&(t.flags|=8192)):de(t),null;case 24:return null;case 25:return null}throw Error(y(156,t.tag))}function Dd(e,t){switch(go(t),t.tag){case 1:return Ne(t.type)&&el(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return xn(),W(ke),W(me),jo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return _o(t),null;case 13:if(W(X),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(y(340));gn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return W(X),null;case 4:return xn(),null;case 10:return So(t.type._context),null;case 22:case 23:return Io(),null;case 24:return null;default:return null}}var zr=!1,pe=!1,Fd=typeof WeakSet=="function"?WeakSet:Set,E=null;function sn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Z(e,t,r)}else n.current=null}function Vi(e,t,n){try{n()}catch(r){Z(e,t,r)}}var Ys=!1;function Od(e,t){if(Ci=Zr,e=oa(),ho(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var o=0,s=-1,a=-1,d=0,h=0,v=e,m=null;t:for(;;){for(var x;v!==n||l!==0&&v.nodeType!==3||(s=o+l),v!==i||r!==0&&v.nodeType!==3||(a=o+r),v.nodeType===3&&(o+=v.nodeValue.length),(x=v.firstChild)!==null;)m=v,v=x;for(;;){if(v===e)break t;if(m===n&&++d===l&&(s=o),m===i&&++h===r&&(a=o),(x=v.nextSibling)!==null)break;v=m,m=v.parentNode}v=x}n=s===-1||a===-1?null:{start:s,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(_i={focusedElem:e,selectionRange:n},Zr=!1,E=t;E!==null;)if(t=E,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,E=e;else for(;E!==null;){t=E;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var N=w.memoizedProps,D=w.memoizedState,p=t.stateNode,c=p.getSnapshotBeforeUpdate(t.elementType===t.type?N:$e(t.type,N),D);p.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var f=t.stateNode.containerInfo;f.nodeType===1?f.textContent="":f.nodeType===9&&f.documentElement&&f.removeChild(f.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(y(163))}}catch(g){Z(t,t.return,g)}if(e=t.sibling,e!==null){e.return=t.return,E=e;break}E=t.return}return w=Ys,Ys=!1,w}function Qn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Vi(t,n,i)}l=l.next}while(l!==r)}}function Cl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Bi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function nc(e){var t=e.alternate;t!==null&&(e.alternate=null,nc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ye],delete t[lr],delete t[Pi],delete t[yd],delete t[xd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function rc(e){return e.tag===5||e.tag===3||e.tag===4}function Gs(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||rc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Hi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=br));else if(r!==4&&(e=e.child,e!==null))for(Hi(e,t,n),e=e.sibling;e!==null;)Hi(e,t,n),e=e.sibling}function Wi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Wi(e,t,n),e=e.sibling;e!==null;)Wi(e,t,n),e=e.sibling}var se=null,Ae=!1;function at(e,t,n){for(n=n.child;n!==null;)lc(e,t,n),n=n.sibling}function lc(e,t,n){if(Ge&&typeof Ge.onCommitFiberUnmount=="function")try{Ge.onCommitFiberUnmount(vl,n)}catch{}switch(n.tag){case 5:pe||sn(n,t);case 6:var r=se,l=Ae;se=null,at(e,t,n),se=r,Ae=l,se!==null&&(Ae?(e=se,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):se.removeChild(n.stateNode));break;case 18:se!==null&&(Ae?(e=se,n=n.stateNode,e.nodeType===8?Xl(e.parentNode,n):e.nodeType===1&&Xl(e,n),bn(e)):Xl(se,n.stateNode));break;case 4:r=se,l=Ae,se=n.stateNode.containerInfo,Ae=!0,at(e,t,n),se=r,Ae=l;break;case 0:case 11:case 14:case 15:if(!pe&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,o=i.destroy;i=i.tag,o!==void 0&&(i&2||i&4)&&Vi(n,t,o),l=l.next}while(l!==r)}at(e,t,n);break;case 1:if(!pe&&(sn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(s){Z(n,t,s)}at(e,t,n);break;case 21:at(e,t,n);break;case 22:n.mode&1?(pe=(r=pe)||n.memoizedState!==null,at(e,t,n),pe=r):at(e,t,n);break;default:at(e,t,n)}}function Zs(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Fd),t.forEach(function(r){var l=Qd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Ue(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~i}if(r=l,r=J()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Ud(r/1960))-r,10e?16:e,ht===null)var r=!1;else{if(e=ht,ht=null,dl=0,U&6)throw Error(y(331));var l=U;for(U|=4,E=e.current;E!==null;){var i=E,o=i.child;if(E.flags&16){var s=i.deletions;if(s!==null){for(var a=0;aJ()-Fo?It(e,0):Do|=n),Ce(e,t)}function dc(e,t){t===0&&(e.mode&1?(t=Sr,Sr<<=1,!(Sr&130023424)&&(Sr=4194304)):t=1);var n=ve();e=it(e,t),e!==null&&(dr(e,t,n),Ce(e,n))}function Wd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),dc(e,n)}function Qd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(y(314))}r!==null&&r.delete(t),dc(e,n)}var pc;pc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ke.current)Se=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return Se=!1,Rd(e,t,n);Se=!!(e.flags&131072)}else Se=!1,K&&t.flags&1048576&&ga(t,rl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Vr(e,t),e=t.pendingProps;var l=vn(t,me.current);pn(t,n),l=Po(null,t,r,e,l,n);var i=To();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ne(r)?(i=!0,tl(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,No(t),l.updater=Nl,t.stateNode=l,l._reactInternals=t,Di(t,r,e,n),t=Ii(null,t,r,!0,i,n)):(t.tag=0,K&&i&&vo(t),he(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Vr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Xd(r),e=$e(r,e),l){case 0:t=Oi(null,t,r,e,n);break e;case 1:t=Qs(null,t,r,e,n);break e;case 11:t=Hs(null,t,r,e,n);break e;case 14:t=Ws(null,t,r,$e(r.type,e),n);break e}throw Error(y(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:$e(r,l),Oi(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:$e(r,l),Qs(e,t,r,l,n);case 3:e:{if(Za(t),e===null)throw Error(y(387));r=t.pendingProps,i=t.memoizedState,l=i.element,Na(e,t),ol(t,r,null,n);var o=t.memoizedState;if(r=o.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=wn(Error(y(423)),t),t=Ks(e,t,r,n,l);break e}else if(r!==l){l=wn(Error(y(424)),t),t=Ks(e,t,r,n,l);break e}else for(je=xt(t.stateNode.containerInfo.firstChild),Ee=t,K=!0,Ve=null,n=Sa(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(gn(),r===l){t=ot(e,t,n);break e}he(e,t,r,n)}t=t.child}return t;case 5:return Ca(t),e===null&&Li(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,o=l.children,ji(r,l)?o=null:i!==null&&ji(r,i)&&(t.flags|=32),Ga(e,t),he(e,t,o,n),t.child;case 6:return e===null&&Li(t),null;case 13:return Ja(e,t,n);case 4:return Co(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=yn(t,null,r,n):he(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:$e(r,l),Hs(e,t,r,l,n);case 7:return he(e,t,t.pendingProps,n),t.child;case 8:return he(e,t,t.pendingProps.children,n),t.child;case 12:return he(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,o=l.value,V(ll,r._currentValue),r._currentValue=o,i!==null)if(We(i.value,o)){if(i.children===l.children&&!ke.current){t=ot(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var s=i.dependencies;if(s!==null){o=i.child;for(var a=s.firstContext;a!==null;){if(a.context===r){if(i.tag===1){a=nt(-1,n&-n),a.tag=2;var d=i.updateQueue;if(d!==null){d=d.shared;var h=d.pending;h===null?a.next=a:(a.next=h.next,h.next=a),d.pending=a}}i.lanes|=n,a=i.alternate,a!==null&&(a.lanes|=n),Ri(i.return,n,t),s.lanes|=n;break}a=a.next}}else if(i.tag===10)o=i.type===t.type?null:i.child;else if(i.tag===18){if(o=i.return,o===null)throw Error(y(341));o.lanes|=n,s=o.alternate,s!==null&&(s.lanes|=n),Ri(o,n,t),o=i.sibling}else o=i.child;if(o!==null)o.return=i;else for(o=i;o!==null;){if(o===t){o=null;break}if(i=o.sibling,i!==null){i.return=o.return,o=i;break}o=o.return}i=o}he(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,pn(t,n),l=Oe(l),r=r(l),t.flags|=1,he(e,t,r,n),t.child;case 14:return r=t.type,l=$e(r,t.pendingProps),l=$e(r.type,l),Ws(e,t,r,l,n);case 15:return Xa(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:$e(r,l),Vr(e,t),t.tag=1,Ne(r)?(e=!0,tl(t)):e=!1,pn(t,n),Wa(t,r,l),Di(t,r,l,n),Ii(null,t,r,!0,e,n);case 19:return qa(e,t,n);case 22:return Ya(e,t,n)}throw Error(y(156,t.tag))};function mc(e,t){return Vu(e,t)}function Kd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function De(e,t,n,r){return new Kd(e,t,n,r)}function $o(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Xd(e){if(typeof e=="function")return $o(e)?1:0;if(e!=null){if(e=e.$$typeof,e===ro)return 11;if(e===lo)return 14}return 2}function Nt(e,t){var n=e.alternate;return n===null?(n=De(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Wr(e,t,n,r,l,i){var o=2;if(r=e,typeof e=="function")$o(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Jt:return Ut(n.children,l,i,t);case no:o=8,l|=8;break;case li:return e=De(12,n,t,l|2),e.elementType=li,e.lanes=i,e;case ii:return e=De(13,n,t,l),e.elementType=ii,e.lanes=i,e;case oi:return e=De(19,n,t,l),e.elementType=oi,e.lanes=i,e;case Cu:return jl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ku:o=10;break e;case Nu:o=9;break e;case ro:o=11;break e;case lo:o=14;break e;case ct:o=16,r=null;break e}throw Error(y(130,e==null?e:typeof e,""))}return t=De(o,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Ut(e,t,n,r){return e=De(7,e,r,t),e.lanes=n,e}function jl(e,t,n,r){return e=De(22,e,r,t),e.elementType=Cu,e.lanes=n,e.stateNode={isHidden:!1},e}function ti(e,t,n){return e=De(6,e,null,t),e.lanes=n,e}function ni(e,t,n){return t=De(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Yd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ol(0),this.expirationTimes=Ol(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ol(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Ao(e,t,n,r,l,i,o,s,a){return e=new Yd(e,t,n,s,a),t===1?(t=1,i===!0&&(t|=8)):t=0,i=De(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},No(i),e}function Gd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(yc)}catch(e){console.error(e)}}yc(),yu.exports=Te;var ep=yu.exports,xc,lu=ep;xc=lu.createRoot,lu.hydrateRoot;async function te(e,t={}){const n=await fetch(e,t);if(!n.ok){let r=n.statusText;try{r=(await n.json()).detail||r}catch{}throw new Error(r)}return n.json()}const Yt=(e,t)=>({method:e,headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),Q={health:()=>te("/api/health"),models:()=>te("/api/models"),languages:()=>te("/api/languages"),listProjects:()=>te("/api/projects"),createProject:e=>te("/api/projects",Yt("POST",e)),patchProject:(e,t)=>te(`/api/projects/${e}`,Yt("PATCH",t)),deleteProject:e=>fetch(`/api/projects/${e}`,{method:"DELETE"}),authMe:()=>te("/api/auth/me"),register:e=>te("/api/auth/register",Yt("POST",e)),login:e=>te("/api/auth/login",Yt("POST",e)),logout:()=>te("/api/auth/logout",{method:"POST"}),listCorpora:e=>te(`/api/projects/${e}/corpora`),uploadCorpus:(e,t)=>{const n=new FormData;return n.append("file",t),te(`/api/projects/${e}/corpora`,{method:"POST",body:n})},listConstructs:()=>te("/api/constructs"),createConstruct:e=>te("/api/constructs",Yt("POST",e)),parseConstructFile:e=>{const t=new FormData;return t.append("file",e),te("/api/constructs/parse-file",{method:"POST",body:t})},createJob:e=>te("/api/jobs",Yt("POST",e)),listJobs:e=>te(`/api/jobs?project_id=${e}`),getJob:e=>te(`/api/jobs/${e}`),jobResults:e=>te(`/api/jobs/${e}/results`),exportUrl:e=>`/api/jobs/${e}/export`,metadataUrl:e=>`/api/jobs/${e}/metadata`,scriptUrl:e=>`/api/jobs/${e}/script`,scriptRequirementsUrl:e=>`/api/jobs/${e}/script-requirements`},wc="ccr_recent_constructs",tp=5;function Sc(){try{return JSON.parse(localStorage.getItem(wc)||"[]")}catch{return[]}}function np(e){const t=[e,...Sc().filter(n=>n!==e)].slice(0,tp);try{localStorage.setItem(wc,JSON.stringify(t))}catch{}}function rp({constructs:e,value:t,onChange:n}){const[r,l]=T.useState(!1),[i,o]=T.useState(""),[s,a]=T.useState(0),d=T.useRef(null),h=T.useRef(null),v=T.useRef(null),m=e.find(c=>c.id===t)||null,x=T.useMemo(()=>{const c=i.trim().toLowerCase(),f=O=>!c||O.name.toLowerCase().includes(c)||(O.category||"").toLowerCase().includes(c),g=e.filter(f),S=[],C=new Set,P=Sc().map(O=>g.find(B=>B.id===O)).filter(Boolean);P.length&&(S.push(["Recently used",P]),P.forEach(O=>C.add(O.id)));const I=g.filter(O=>!O.is_seed&&!C.has(O.id));I.length&&(S.push(["My custom constructs",I]),I.forEach(O=>C.add(O.id)));const L=new Map;for(const O of g){if(C.has(O.id))continue;const B=O.category||"Other";L.has(B)||L.set(B,[]),L.get(B).push(O)}for(const O of[...L.keys()].sort((B,oe)=>B.localeCompare(oe)))S.push([O,L.get(O).sort((B,oe)=>B.name.localeCompare(oe.name))]);return S},[e,i]),w=T.useMemo(()=>x.flatMap(([,c])=>c),[x]);T.useEffect(()=>a(0),[i,r]),T.useEffect(()=>{if(!r)return;const c=f=>{d.current&&!d.current.contains(f.target)&&l(!1)};return document.addEventListener("mousedown",c),()=>document.removeEventListener("mousedown",c)},[r]),T.useEffect(()=>{var f;const c=(f=v.current)==null?void 0:f.querySelector('[data-active="true"]');c==null||c.scrollIntoView({block:"nearest"})},[s,r]);function N(c){n(c.id),np(c.id),l(!1),o("")}function D(c){c.key==="ArrowDown"?(c.preventDefault(),a(f=>Math.min(f+1,w.length-1))):c.key==="ArrowUp"?(c.preventDefault(),a(f=>Math.max(f-1,0))):c.key==="Enter"?(c.preventDefault(),w[s]&&N(w[s])):c.key==="Escape"&&l(!1)}let p=-1;return u.jsx("div",{className:"picker",ref:d,children:r?u.jsxs(u.Fragment,{children:[u.jsx("input",{ref:h,type:"text",role:"combobox","aria-expanded":"true","aria-autocomplete":"list",className:"picker-search",placeholder:"Search by scale, construct, or category (e.g. empathy, GAD-7)",value:i,onChange:c=>o(c.target.value),onKeyDown:D}),u.jsxs("div",{className:"picker-panel",role:"listbox",ref:v,children:[w.length===0&&u.jsxs("p",{className:"small muted picker-empty",children:['No constructs match "',i,'". Try a scale abbreviation or use + Custom construct.']}),x.map(([c,f])=>u.jsxs("div",{children:[u.jsx("div",{className:"picker-group",children:c}),f.map(g=>{p+=1;const S=p===s;return u.jsxs("div",{role:"option","aria-selected":g.id===t,"data-active":S||void 0,className:"picker-option"+(S?" active":"")+(g.id===t?" selected":""),onMouseDown:C=>{C.preventDefault(),N(g)},children:[u.jsx("span",{className:"picker-name",children:g.name}),u.jsxs("span",{className:"picker-meta",children:[g.category?`${g.category} · `:"",g.items.length," item",g.items.length===1?"":"s",g.verification_status!=="verified"?" · unverified":""]})]},g.id)})]},c))]})]}):u.jsxs("button",{type:"button",className:"picker-display","aria-haspopup":"listbox","aria-expanded":"false",onClick:()=>{l(!0),setTimeout(()=>{var c;return(c=h.current)==null?void 0:c.focus()},0)},children:[m?u.jsxs(u.Fragment,{children:[u.jsx("span",{children:m.name}),u.jsxs("span",{className:"picker-meta",children:[m.items.length," item",m.items.length===1?"":"s"]})]}):u.jsxs("span",{className:"muted",children:["Select a construct (",e.length," in library)"]}),u.jsx("span",{className:"picker-caret","aria-hidden":"true",children:"▾"})]})})}function lp({jobId:e,onBack:t}){var d;const[n,r]=T.useState(null),[l,i]=T.useState("");if(T.useEffect(()=>{Q.jobResults(e).then(r).catch(h=>i(h.message))},[e]),l)return u.jsxs("div",{className:"card",children:[u.jsx("div",{className:"error-banner",children:l}),u.jsx("button",{className:"ghost",onClick:t,children:"← Back"})]});if(!n)return u.jsx("div",{className:"card",children:"Loading results…"});const{summary:o,metadata:s}=n,a=Math.max(...o.item_means.map(h=>Math.abs(h.mean)),1e-9);return u.jsxs(u.Fragment,{children:[u.jsxs("div",{className:"results-toolbar",children:[u.jsx("button",{className:"ghost",onClick:t,children:"← Back to workspace"}),u.jsxs("div",{className:"row result-actions",children:[u.jsx("a",{href:Q.exportUrl(e),children:u.jsx("button",{className:"primary",children:"Export results CSV"})}),u.jsx("a",{href:Q.scriptUrl(e),children:u.jsx("button",{className:"ghost",children:"Python script"})}),u.jsx("a",{href:Q.scriptRequirementsUrl(e),children:u.jsx("button",{className:"ghost",children:"requirements.txt"})}),u.jsx("a",{href:Q.metadataUrl(e),children:u.jsx("button",{className:"ghost",children:"Run metadata (JSON)"})})]})]}),u.jsxs("div",{className:"card",children:[u.jsxs("h3",{children:[s.construct," × ",s.corpus_file]}),u.jsx("p",{className:"hint",children:"CCR score = mean cosine similarity between each text and the construct's scale items. Higher = the text expresses the construct more strongly."}),u.jsxs("div",{className:"stat-grid",children:[u.jsx(Gt,{k:"Texts scored",v:o.n_docs.toLocaleString()}),u.jsx(Gt,{k:"Mean score",v:o.score_mean.toFixed(3)}),u.jsx(Gt,{k:"SD",v:o.score_sd.toFixed(3)}),u.jsx(Gt,{k:"Min",v:o.score_min.toFixed(3)}),u.jsx(Gt,{k:"Max",v:o.score_max.toFixed(3)}),o.n_dropped_empty>0&&u.jsx(Gt,{k:"Empty rows dropped",v:o.n_dropped_empty})]}),((d=o.warnings)==null?void 0:d.length)>0&&u.jsxs("div",{className:"warnings mt",children:[u.jsx("strong",{className:"small",children:"Data-quality notes"}),u.jsx("ul",{className:"small",style:{margin:"4px 0 0",paddingLeft:20},children:o.warnings.map((h,v)=>u.jsx("li",{children:typeof h=="string"?h:u.jsxs(u.Fragment,{children:[u.jsx("code",{style:{fontSize:11},children:h.code})," - ",h.message]})},v))})]})]}),u.jsxs("div",{className:"card",children:[u.jsx("h3",{children:"Score distribution"}),u.jsx(ip,{histogram:o.histogram})]}),u.jsxs("div",{className:"card",children:[u.jsx("h3",{children:"Per-item mean loadings"}),u.jsx("p",{className:"hint",children:"Mean similarity of the corpus to each scale item - a face-validity check on which items drive the construct signal."}),o.item_means.map((h,v)=>u.jsxs("div",{className:"item-bar-row",children:[u.jsx("span",{className:"item-bar-label",title:h.item,children:h.item.length>80?h.item.slice(0,80)+"…":h.item}),u.jsx("div",{className:"item-bar-track",children:u.jsx("div",{className:"item-bar-fill",style:{width:`${Math.max(2,Math.abs(h.mean)/a*100)}%`}})}),u.jsx("span",{className:"item-bar-val",children:h.mean.toFixed(3)})]},v))]}),u.jsxs("div",{className:"row",children:[u.jsxs("div",{className:"grow card",children:[u.jsx("h3",{children:"Highest-scoring texts"}),u.jsx(iu,{docs:o.top_docs})]}),u.jsxs("div",{className:"grow card",children:[u.jsx("h3",{children:"Lowest-scoring texts"}),u.jsx(iu,{docs:o.bottom_docs})]})]}),u.jsxs("div",{className:"meta-footer",children:[u.jsx("strong",{children:"Reproducibility record"})," - model: ",u.jsx("code",{children:s.model})," (dim"," ",s.embedding_dim,") · items hash: ",u.jsx("code",{children:s.items_sha256_16})," · text column: ",u.jsx("code",{children:s.text_column})," · run:"," ",s.started_at," → ",s.finished_at," (",s.duration_seconds,"s) · numpy ",s.numpy,s.sentence_transformers&&` · sentence-transformers ${s.sentence_transformers}`,u.jsxs("div",{className:"mt small",children:["Construct reference: ",s.construct_reference||"-"]})]})]})}function Gt({k:e,v:t}){return u.jsxs("div",{className:"stat",children:[u.jsx("div",{className:"v",children:t}),u.jsx("div",{className:"k",children:e})]})}function iu({docs:e}){return u.jsx("div",{className:"table-wrap",children:u.jsxs("table",{className:"docs",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{style:{width:60},children:"Score"}),u.jsx("th",{children:"Text"})]})}),u.jsx("tbody",{children:e.map(t=>u.jsxs("tr",{children:[u.jsx("td",{className:"score",children:t.score.toFixed(3)}),u.jsx("td",{children:t.text})]},t.row))})]})})}function ip({histogram:e}){const{counts:t,edges:n}=e,r=640,l=180,i={top:10,right:10,bottom:26,left:34},o=r-i.left-i.right,s=l-i.top-i.bottom,a=Math.max(...t,1),d=o/t.length;return u.jsxs("svg",{viewBox:`0 0 ${r} ${l}`,style:{width:"100%",maxWidth:720},children:[[.25,.5,.75,1].map(h=>{const v=i.top+s-h*s;return u.jsxs("g",{children:[u.jsx("line",{x1:i.left,x2:r-i.right,y1:v,y2:v,stroke:"#eceef1"}),u.jsx("text",{x:i.left-6,y:v+4,fontSize:"10",fill:"#98a2b3",textAnchor:"end",children:Math.round(h*a)})]},h)}),t.map((h,v)=>{const m=h/a*s;return u.jsx("rect",{x:i.left+v*d+1.5,y:i.top+s-m,width:Math.max(1,d-3),height:m,rx:"2",fill:"#7a1f3d",opacity:"0.85",children:u.jsxs("title",{children:[n[v].toFixed(3)," – ",n[v+1].toFixed(3),": ",h]})},v)}),[0,Math.floor(t.length/2),t.length].map(h=>u.jsx("text",{x:i.left+h*d,y:l-8,fontSize:"10",fill:"#98a2b3",textAnchor:"middle",children:n[h].toFixed(2)},h)),u.jsx("line",{x1:i.left,x2:r-i.right,y1:i.top+s,y2:i.top+s,stroke:"#d0d5dd"})]})}function op({project:e,auth:t,onAuthRefresh:n,onProjectChanged:r,onProjectDeleted:l}){var Wo,Qo,Ko,Xo,Yo,Go;const[i,o]=T.useState([]),[s,a]=T.useState([]),[d,h]=T.useState([]),[v,m]=T.useState([]),[x,w]=T.useState(""),[N,D]=T.useState(""),[p,c]=T.useState(""),[f,g]=T.useState(""),[S,C]=T.useState(["en"]),[j,P]=T.useState("en"),[I,L]=T.useState(!1),[O,B]=T.useState(!1),[oe,ce]=T.useState(""),[Kt,ut]=T.useState(!1),[Qe,_]=T.useState(null),[R,M]=T.useState(!1),[A,z]=T.useState(""),ee=T.useRef(null);async function q(){try{await Q.patchProject(e.id,{archived:!e.archived}),r==null||r()}catch(k){ce(k.message)}}async function _n(){try{await Q.deleteProject(e.id),M(!1),l==null||l()}catch(k){ce(k.message)}}const xe=T.useCallback(()=>Q.listJobs(e.id).then(m).catch(()=>{}),[e.id]);T.useEffect(()=>{Q.listCorpora(e.id).then(o).catch(k=>ce(k.message)),Q.listConstructs().then(a).catch(k=>ce(k.message)),Q.models().then(k=>{h(k);const Le=k.find(Lt=>Lt.default)||k[0];Le&&g(Le.id)}).catch(k=>ce(k.message)),Q.languages().then(C).catch(()=>{}),xe()},[e.id,xe]);const Tt=v.some(k=>k.status==="queued"||k.status==="running");T.useEffect(()=>{if(!Tt)return;const k=setInterval(xe,1200);return()=>clearInterval(k)},[Tt,xe]);const Je=i.find(k=>k.id===x)||null,zt=s.find(k=>k.id===p)||null;async function kc(k){var Lt;const Le=(Lt=k.target.files)==null?void 0:Lt[0];if(Le){L(!0),ce("");try{const jn=await Q.uploadCorpus(e.id,Le),_c=await Q.listCorpora(e.id);o(_c),w(jn.id),D(jn.suggested_text_column||jn.columns[0])}catch(jn){ce(jn.message)}finally{L(!1),ee.current&&(ee.current.value="")}}}async function Nc(){B(!0),ce("");try{await Q.createJob({project_id:e.id,corpus_id:x,construct_id:p,text_column:N,model_name:f,language:j}),await xe(),n==null||n()}catch(k){ce(k.message)}finally{B(!1)}}if(Qe)return u.jsx(lp,{jobId:Qe,onBack:()=>{_(null),xe()}});const Cc=x&&N&&p&&f&&!O;return u.jsxs(u.Fragment,{children:[oe&&u.jsx("div",{className:"error-banner",onClick:()=>ce(""),children:oe}),u.jsxs("div",{className:"project-header",children:[u.jsxs("div",{children:[u.jsx("span",{className:"project-title",children:e.name}),e.archived&&u.jsx("span",{className:"pill queued",children:"archived"})]}),u.jsxs("div",{className:"row",children:[u.jsx("button",{className:"ghost",onClick:q,children:e.archived?"Unarchive":"Archive"}),u.jsx("button",{className:"ghost danger",onClick:()=>M(!0),children:"Delete"})]})]}),R&&u.jsx("div",{className:"modal-backdrop",onClick:()=>M(!1),children:u.jsxs("div",{className:"modal",onClick:k=>k.stopPropagation(),children:[u.jsxs("h3",{children:['Delete "',e.name,'"?']}),u.jsxs("p",{className:"hint",children:["This permanently deletes ",i.length," dataset",i.length===1?"":"s",","," ",v.length," run",v.length===1?"":"s",", and all uploaded and result files. This cannot be undone. If you might need it later, use Archive instead."]}),u.jsxs("label",{className:"field",children:["Type the project name to confirm",u.jsx("input",{type:"text",autoFocus:!0,value:A,onChange:k=>z(k.target.value),placeholder:e.name})]}),u.jsxs("div",{className:"row",children:[u.jsx("button",{className:"primary danger-solid",disabled:A!==e.name,onClick:_n,children:"Delete permanently"}),u.jsx("button",{className:"ghost",onClick:()=>{M(!1),z("")},children:"Cancel"})]})]})}),u.jsxs("div",{className:"card",children:[u.jsxs("h3",{children:[u.jsx("span",{className:"step-badge",children:"1"}),"Corpus"]}),u.jsxs("p",{className:"hint",children:["Upload a CSV or XLSX file, then choose the column containing the text to analyze.",t&&!t.signed_in&&((Wo=t.limits)==null?void 0:Wo.max_rows)&&u.jsxs(u.Fragment,{children:[" ","Anonymous limit: ",Math.round(t.limits.max_bytes/1048576)," MB /"," ",t.limits.max_rows.toLocaleString()," rows per file; uploads are deleted after analysis. Sign in (top right) for larger uploads and to keep your data."]})]}),u.jsxs("div",{className:"row",children:[u.jsxs("div",{className:"grow",children:[u.jsxs("label",{className:"field",children:["Upload file",u.jsx("input",{ref:ee,type:"file",accept:".csv,.xlsx,.xls",onChange:kc,disabled:I})]}),I&&u.jsx("span",{className:"small muted",children:"Uploading…"})]}),u.jsx("div",{className:"grow",children:u.jsxs("label",{className:"field",children:["Corpus",u.jsxs("select",{value:x,onChange:k=>w(k.target.value),children:[u.jsx("option",{value:"",children:"- select -"}),i.map(k=>u.jsxs("option",{value:k.id,children:[k.filename," (",k.n_rows.toLocaleString()," rows)"]},k.id))]})]})}),u.jsx("div",{className:"grow",children:u.jsxs("label",{className:"field",children:["Text column",u.jsxs("select",{value:N,onChange:k=>D(k.target.value),disabled:!Je,children:[u.jsx("option",{value:"",children:"- select -"}),Je==null?void 0:Je.columns.map(k=>u.jsxs("option",{value:k,children:[k,k===Je.suggested_text_column?" (suggested)":""]},k))]})]})})]}),((Qo=Je==null?void 0:Je.parse_info)==null?void 0:Qo.note)&&u.jsxs("p",{className:"small muted",children:["⚠ ",Je.parse_info.note]})]}),u.jsxs("div",{className:"card",children:[u.jsxs("h3",{children:[u.jsx("span",{className:"step-badge",children:"2"}),"Construct"]}),u.jsx("p",{className:"hint",children:"Pick a validated scale from the library, or define custom items. CCR scores each text by its similarity to these items."}),u.jsxs("div",{className:"construct-row",children:[u.jsx("div",{className:"grow",children:u.jsx(rp,{constructs:s,value:p,onChange:c})}),u.jsx("button",{className:"ghost",onClick:()=>ut(k=>!k),children:Kt?"Close":"+ Custom construct"})]}),zt&&u.jsxs(u.Fragment,{children:[u.jsx("ul",{className:"construct-items",children:zt.items.map((k,Le)=>{var Lt;return u.jsxs("li",{children:[k,(Lt=zt.reverse_scored)!=null&&Lt[Le]?" (reverse-scored)":""]},Le)})}),zt.reference&&u.jsxs("p",{className:"small muted mt",children:["Reference: ",zt.reference]}),zt.verification_status!=="verified"&&u.jsxs("p",{className:"small muted",children:["⚠ Item wording not yet verified verbatim against the original publication (status: ",zt.verification_status.replace("_"," "),")."]})]}),Kt&&u.jsx(sp,{onCreated:async k=>{const Le=await Q.listConstructs();a(Le),c(k.id),ut(!1)},onError:ce})]}),u.jsxs("div",{className:"card",children:[u.jsxs("h3",{children:[u.jsx("span",{className:"step-badge",children:"3"}),"Language, model & run"]}),u.jsx("p",{className:"hint",children:"Embeddings run locally via sentence-transformers; model and language are recorded in the run metadata. If the corpus doesn't match the selected language or the model doesn't support it, you'll get a warning - never a silent result."}),u.jsxs("div",{className:"run-settings",children:[u.jsxs("label",{className:"field language-control",children:["Text language",u.jsx("select",{value:j,onChange:k=>P(k.target.value),children:S.map(k=>u.jsx("option",{value:k,children:k},k))})]}),u.jsxs("label",{className:"field model-control",children:["Embedding model",u.jsx("select",{value:f,onChange:k=>g(k.target.value),children:d.map(k=>u.jsx("option",{value:k.id,children:k.label},k.id))})]}),u.jsx("button",{className:"primary run-button",disabled:!Cc,onClick:Nc,children:O?"Starting…":"Run CCR analysis"})]}),t&&!t.signed_in&&((Ko=t.usage)==null?void 0:Ko.max_runs_per_day)!=null&&u.jsxs("p",{className:"small muted",children:[Math.min(t.usage.runs_used_today,t.usage.max_runs_per_day)," of"," ",t.usage.max_runs_per_day," free runs used today",t.usage.runs_used_today>=t.usage.max_runs_per_day?" - sign in (top right) to keep running.":"."]}),(t==null?void 0:t.signed_in)&&((Xo=t.usage)==null?void 0:Xo.max_saved_runs)!=null&&u.jsxs("p",{className:"small muted",children:[t.usage.saved_runs," of ",t.usage.max_saved_runs," saved runs used."]}),(Go=(Yo=d.find(k=>k.id===f))==null?void 0:Yo.warnings)==null?void 0:Go.map((k,Le)=>u.jsxs("p",{className:"small muted",children:["⚠ ",k]},Le))]}),v.length>0&&u.jsxs("div",{className:"card",children:[u.jsx("h3",{children:"Runs"}),u.jsx("div",{className:"table-wrap",children:u.jsxs("table",{className:"docs",children:[u.jsx("thead",{children:u.jsxs("tr",{children:[u.jsx("th",{children:"Started"}),u.jsx("th",{children:"Corpus"}),u.jsx("th",{children:"Construct"}),u.jsx("th",{children:"Model"}),u.jsx("th",{children:"Lang"}),u.jsx("th",{style:{width:"20%"},children:"Status"}),u.jsx("th",{})]})}),u.jsx("tbody",{children:v.map(k=>u.jsxs("tr",{children:[u.jsx("td",{className:"muted",children:(k.started_at||k.created_at).replace("T"," ").slice(0,16)}),u.jsx("td",{children:k.corpus_filename}),u.jsx("td",{children:k.construct_name}),u.jsx("td",{className:"muted small",children:k.model_name}),u.jsx("td",{className:"muted small",children:k.language}),u.jsxs("td",{children:[k.status==="running"?u.jsx("div",{className:"progress-track",title:`${Math.round(k.progress*100)}%`,children:u.jsx("div",{className:"progress-fill",style:{width:`${Math.max(3,k.progress*100)}%`}})}):u.jsx("span",{className:`pill ${k.status}`,children:k.status}),k.status==="failed"&&u.jsx("div",{className:"small muted",title:k.error,children:k.error.split(` +`).pop()})]}),u.jsx("td",{children:k.status==="completed"&&u.jsx("button",{className:"linkish",onClick:()=>_(k.id),children:"View results"})})]},k.id))})]})})]})]})}const ou=/\s*\((r|rev|reversed)\)\s*$/i;function sp({onCreated:e,onError:t}){const[n,r]=T.useState(""),[l,i]=T.useState(""),[o,s]=T.useState(""),[a,d]=T.useState(!1),[h,v]=T.useState(!1),[m,x]=T.useState([]),w=T.useRef(null);function N(){return o.split(` +`).map(f=>f.trim()).filter(Boolean).map(f=>({text:f.replace(ou,"").trim(),reverse:ou.test(f)}))}async function D(f){var S;const g=(S=f.target.files)==null?void 0:S[0];if(g){v(!0),x([]);try{const C=await Q.parseConstructFile(g);s(C.items.map(j=>j.reverse_scored?`${j.text} (R)`:j.text).join(` +`)),!n.trim()&&C.suggested_name&&r(C.suggested_name),x(C.warnings||[])}catch(C){t(C.message)}finally{v(!1),w.current&&(w.current.value="")}}}async function p(f){f.preventDefault();const g=N();if(!n.trim()||g.length===0){t("A custom construct needs a name and at least one item (one per line).");return}d(!0);try{const S=await Q.createConstruct({name:n.trim(),reference:l,items:g.map(C=>C.text),reverse_scored:g.map(C=>C.reverse)});e(S)}catch(S){t(S.message)}finally{d(!1)}}const c=N().filter(f=>f.reverse).length;return u.jsxs("form",{onSubmit:p,className:"mt",children:[u.jsxs("div",{className:"row",children:[u.jsx("div",{className:"grow",children:u.jsxs("label",{className:"field",children:["Name",u.jsx("input",{type:"text",value:n,onChange:f=>r(f.target.value)})]})}),u.jsx("div",{className:"grow",children:u.jsxs("label",{className:"field",children:["Reference (publication, optional)",u.jsx("input",{type:"text",value:l,onChange:f=>i(f.target.value)})]})})]}),u.jsxs("label",{className:"field",children:['Upload items from CSV/XLSX (optional) - an "item" column, or one item per row; reverse-scored via a "reverse" column or a trailing (R)',u.jsx("input",{ref:w,type:"file",accept:".csv,.xlsx,.xls",onChange:D,disabled:h})]}),h&&u.jsx("p",{className:"small muted",children:"Parsing…"}),m.map((f,g)=>u.jsxs("p",{className:"small muted",children:["⚠ ",f]},g)),u.jsxs("label",{className:"field",children:["Scale items - one per line, verbatim from the validated instrument; append (R) to mark a reverse-scored item",u.jsx("textarea",{rows:6,value:o,onChange:f=>s(f.target.value)})]}),c>0&&u.jsxs("p",{className:"small muted",children:[c," item(s) marked reverse-scored."]}),u.jsx("button",{className:"primary",type:"submit",disabled:a||h,children:a?"Saving…":"Save construct"})]})}function up(e){if(!e)return"";const t=new Date(e.endsWith("Z")||e.includes("+")?e:e+"Z"),n=Math.max(0,Math.floor((Date.now()-t.getTime())/6e4));if(n<1)return"just now";if(n<60)return`${n}m ago`;const r=Math.floor(n/60);if(r<24)return`${r}h ago`;const l=Math.floor(r/24);return l<7?`${l}d ago`:t.toISOString().slice(0,10)}function ap(e){const t=Date.now(),n=864e5,r={Today:[],"This week":[],Earlier:[],Archived:[]};for(const l of e){if(l.archived){r.Archived.push(l);continue}const i=l.last_activity_at||l.created_at,o=new Date(i.endsWith("Z")||i.includes("+")?i:i+"Z").getTime(),s=t-o;sl.length>0)}function cp(){var M,A;const[e,t]=T.useState([]),[n,r]=T.useState(null),[l,i]=T.useState(!1),[o,s]=T.useState(""),[a,d]=T.useState(""),[h,v]=T.useState(""),[m,x]=T.useState(null),[w,N]=T.useState(!1),[D,p]=T.useState("signin"),[c,f]=T.useState(""),[g,S]=T.useState(""),[C,j]=T.useState(""),[P,I]=T.useState(""),[L,O]=T.useState(!1),B=()=>Q.listProjects().then(t).catch(z=>v(z.message)),oe=()=>Q.authMe().then(x).catch(()=>{});T.useEffect(()=>{B(),oe();const ee=new URLSearchParams(window.location.search).get("auth_error");ee&&(v(`Sign-in problem: ${ee.replaceAll("-"," ")}.`),window.history.replaceState({},"","/"))},[]);async function ce(z){z.preventDefault(),I(""),O(!0);try{D==="register"?await Q.register({email:c.trim(),password:g,name:C.trim()}):await Q.login({email:c.trim(),password:g}),N(!1),f(""),S(""),j(""),await Promise.all([oe(),B()])}catch(ee){I(ee.message)}finally{O(!1)}}async function Kt(){try{await Q.logout(),await Promise.all([oe(),B()])}catch(z){v(z.message)}}T.useEffect(()=>{if(e.length===0){r(null);return}(!n||!e.some(z=>z.id===n))&&r(e[0].id)},[e,n]);async function ut(z){if(z.preventDefault(),!!o.trim())try{const ee=await Q.createProject({name:o.trim()});s(""),i(!1),await B(),r(ee.id)}catch(ee){v(ee.message)}}const Qe=e.find(z=>z.id===n)||null,_=a.trim().toLowerCase(),R=e.filter(z=>z.name.toLowerCase().includes(_));return u.jsxs("div",{className:"app",children:[u.jsxs("header",{className:"header",children:[u.jsx("h1",{children:"CCR Platform"}),u.jsx("span",{className:"sub",children:"Contextualized Construct Representations · theory-driven psychological text analysis"}),u.jsx("span",{className:"header-auth",children:m!=null&&m.signed_in?u.jsxs(u.Fragment,{children:[u.jsxs("span",{className:"small",children:["Hi, ",m.name]}),u.jsx("button",{className:"header-btn",onClick:Kt,children:"Sign out"})]}):u.jsx("button",{className:"header-btn",onClick:()=>N(!0),children:"Sign in"})})]}),w&&u.jsx("div",{className:"modal-backdrop",onClick:()=>N(!1),children:u.jsxs("div",{className:"modal",onClick:z=>z.stopPropagation(),children:[u.jsx("h3",{children:D==="register"?"Create an account":"Sign in"}),u.jsxs("p",{className:"hint",children:["Accounts are free. Signing in lifts the anonymous limits",(M=m==null?void 0:m.limits)!=null&&M.max_rows?` (${Math.round(m.limits.max_bytes/1048576)} MB / ${m.limits.max_rows.toLocaleString()} rows per file, ${((A=m==null?void 0:m.usage)==null?void 0:A.max_runs_per_day)??3} runs/day)`:""," ","and keeps your datasets and runs instead of deleting them after analysis."]}),P&&u.jsx("p",{className:"small",style:{color:"var(--danger, #b3261e)"},children:P}),(m==null?void 0:m.google_available)&&u.jsxs(u.Fragment,{children:[u.jsx("a",{className:"primary google-btn",href:"/api/auth/google/login",children:"Continue with Google"}),u.jsx("p",{className:"small muted",style:{textAlign:"center",margin:"8px 0"},children:"or use email and password"})]}),u.jsxs("form",{onSubmit:ce,className:"mt",children:[D==="register"&&u.jsxs("label",{className:"field",children:["Name",u.jsx("input",{type:"text",autoFocus:!0,value:C,onChange:z=>j(z.target.value),placeholder:"e.g. Mohammad"})]}),u.jsxs("label",{className:"field",children:["Email",u.jsx("input",{type:"email",autoFocus:D==="signin",value:c,onChange:z=>f(z.target.value),placeholder:"you@example.com"})]}),u.jsxs("label",{className:"field",children:["Password",D==="register"&&u.jsx("span",{className:"field-hint",children:" at least 8 characters"}),u.jsx("input",{type:"password",value:g,onChange:z=>S(z.target.value)})]}),u.jsxs("div",{className:"row",children:[u.jsx("button",{className:"primary",type:"submit",disabled:L||!c.trim()||!g||D==="register"&&!C.trim(),children:L?"…":D==="register"?"Create account":"Sign in"}),u.jsx("button",{className:"ghost",type:"button",onClick:()=>N(!1),children:"Cancel"})]})]}),u.jsxs("p",{className:"small muted mt",children:[D==="register"?u.jsxs(u.Fragment,{children:["Already have an account?"," ",u.jsx("button",{className:"linkish",onClick:()=>{p("signin"),I("")},children:"Sign in"})]}):u.jsxs(u.Fragment,{children:["New here?"," ",u.jsx("button",{className:"linkish",onClick:()=>{p("register"),I("")},children:"Create a free account"})]}),m!=null&&m.google_available?" · Forgot your password? Contact the lab admin, or use Google.":" · Google sign-in arrives with lab accounts. Forgot your password? Contact the lab admin."]})]})}),u.jsxs("div",{className:"layout",children:[u.jsxs("aside",{className:"sidebar",children:[u.jsxs("h2",{children:["Projects",e.length>0&&u.jsx("span",{className:"count",children:e.length})]}),u.jsx("input",{type:"text",className:"sidebar-filter",placeholder:"Search projects...",value:a,onChange:z=>d(z.target.value)}),u.jsxs("div",{className:"project-list",children:[ap(R).map(([z,ee])=>u.jsxs("div",{children:[u.jsx("div",{className:"group-label",children:z}),ee.map(q=>u.jsxs("button",{className:"project-item"+(q.id===n?" active":""),onClick:()=>r(q.id),title:q.name,children:[u.jsx("span",{className:"project-name",children:q.name}),u.jsxs("span",{className:"date",children:[q.n_runs>0?`${q.n_runs} run${q.n_runs===1?"":"s"} · `:"",up(q.last_activity_at||q.created_at)]})]},q.id))]},z)),a&&R.length===0&&u.jsxs("p",{className:"small muted",children:['No projects match "',a,'".']})]}),u.jsx("div",{className:"project-create",children:l?u.jsxs("form",{onSubmit:ut,children:[u.jsx("input",{type:"text",autoFocus:!0,placeholder:"Project name",value:o,onChange:z=>s(z.target.value)}),u.jsxs("div",{className:"row mt",children:[u.jsx("button",{className:"primary",type:"submit",children:"Create"}),u.jsx("button",{className:"ghost",type:"button",onClick:()=>i(!1),children:"Cancel"})]})]}):u.jsx("button",{className:"ghost",onClick:()=>i(!0),children:"+ New project"})})]}),u.jsxs("main",{className:"main",children:[h&&u.jsx("div",{className:"error-banner",onClick:()=>v(""),children:h}),Qe?u.jsx(op,{project:Qe,auth:m,onAuthRefresh:oe,onProjectChanged:B,onProjectDeleted:()=>{r(null),B()}},Qe.id):u.jsxs("div",{className:"card",children:[u.jsx("h3",{children:"Welcome"}),u.jsx("p",{className:"hint",children:"Create or select a project, upload a corpus (CSV/XLSX), choose a validated construct, and run a CCR analysis. Results include per-item loadings, score distributions, and a reproducibility record for every run."}),u.jsx("p",{className:"small muted",children:"Self-contained by design: embeddings run on this server itself - no third-party AI APIs. Demo instance: storage is ephemeral and may reset; please don't upload sensitive or identifiable data."})]})]})]})]})}xc(document.getElementById("root")).render(u.jsx(Bc.StrictMode,{children:u.jsx(cp,{})})); diff --git a/backend/static/index.html b/backend/static/index.html index 2d5d1b9b5a610e7a4d9cea114d8c3e43043560e3..d405829bc0df32868ec16f51286ac0dd0da48af1 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -3,9 +3,9 @@ - CCR Platform — Contextualized Construct Representations - - + CCR Platform - Contextualized Construct Representations + +
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 83cd1b00fa81da28f6961de253b9f90a4d76fec0..0584c0c6ff5c374c465b081d90bf2d3623bae740 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,6 +9,9 @@ import tempfile from pathlib import Path os.environ["CCR_DATA_DIR"] = tempfile.mkdtemp(prefix="ccr_test_") +# Tests that exercise the anonymous run limit set this themselves; everything +# else should not trip over it while running multiple jobs per test. +os.environ.setdefault("CCR_ANON_MAX_RUNS_PER_DAY", "1000") # Make `app` importable regardless of pytest invocation directory. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/backend/tests/test_accounts_limits_retention.py b/backend/tests/test_accounts_limits_retention.py new file mode 100644 index 0000000000000000000000000000000000000000..9217b53498cffced05f3fe44b461438fb1ee9115 --- /dev/null +++ b/backend/tests/test_accounts_limits_retention.py @@ -0,0 +1,311 @@ +"""Accounts (register/login), anonymous run limits, retention (delete-after- +analysis + TTL purge), saved-run cap, ownership, and construct file upload.""" + +import io +import time + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture() +def client(): + with TestClient(app) as c: + yield c + + +def csv_rows(n: int) -> bytes: + return ("text\n" + "\n".join(f"sample sentence number {i} here" for i in range(n))).encode() + + +def upload(client, project_id, name, payload: bytes): + return client.post( + f"/api/projects/{project_id}/corpora", + files={"file": (name, io.BytesIO(payload), "text/csv")}, + ) + + +def register(client, email="user@test.edu", name="Test User"): + resp = client.post( + "/api/auth/register", json={"email": email, "password": "password123", "name": name} + ) + assert resp.status_code == 201, resp.json() + return resp.json() + + +def run_job(client, project_id, corpus_id, construct_id): + return client.post( + "/api/jobs", + json={ + "project_id": project_id, + "corpus_id": corpus_id, + "construct_id": construct_id, + "text_column": "text", + "model_name": "fake-deterministic", + }, + ) + + +def wait_for_job(client, job_id, timeout=10.0): + deadline = time.time() + timeout + while time.time() < deadline: + job = client.get(f"/api/jobs/{job_id}").json() + if job["status"] in ("completed", "failed"): + return job + time.sleep(0.05) + raise TimeoutError(job_id) + + +def any_construct(client): + return client.get("/api/constructs").json()[0] + + +# ---------------------------------------------------------------- accounts +def test_register_login_logout_roundtrip(client): + register(client, "roundtrip@test.edu", "Rounder") + me = client.get("/api/auth/me").json() + assert me["signed_in"] and me["email"] == "roundtrip@test.edu" + assert me["usage"]["max_saved_runs"] > 0 + + client.post("/api/auth/logout") + assert client.get("/api/auth/me").json()["signed_in"] is False + + resp = client.post( + "/api/auth/login", json={"email": "ROUNDTRIP@test.edu", "password": "password123"} + ) + assert resp.status_code == 200 # email is case-insensitive + assert client.get("/api/auth/me").json()["signed_in"] is True + + +def test_wrong_password_and_duplicate_email(client): + register(client, "dupe@test.edu") + client.post("/api/auth/logout") + resp = client.post("/api/auth/login", json={"email": "dupe@test.edu", "password": "wrongpass1"}) + assert resp.status_code == 401 + resp = client.post( + "/api/auth/register", json={"email": "dupe@test.edu", "password": "password123", "name": "X"} + ) + assert resp.status_code == 409 + + +def test_register_validation(client): + resp = client.post( + "/api/auth/register", json={"email": "not-an-email", "password": "password123", "name": "X"} + ) + assert resp.status_code == 400 + + +# ------------------------------------------------------ anonymous run limit +def test_anonymous_daily_run_limit(client, monkeypatch): + monkeypatch.setenv("CCR_ANON_MAX_RUNS_PER_DAY", "2") + project = client.post("/api/projects", json={"name": "Limited"}).json() + construct = any_construct(client) + + for i in range(2): + corpus = upload(client, project["id"], f"c{i}.csv", csv_rows(5)).json() + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + assert resp.status_code == 201, resp.json() + wait_for_job(client, resp.json()["id"]) + + me = client.get("/api/auth/me").json() + assert me["usage"]["runs_used_today"] == 2 + + corpus = upload(client, project["id"], "c3.csv", csv_rows(5)).json() + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + assert resp.status_code == 429 + assert "Sign in" in resp.json()["detail"] + + +def test_signed_in_users_bypass_run_limit(client, monkeypatch): + monkeypatch.setenv("CCR_ANON_MAX_RUNS_PER_DAY", "1") + register(client, "runner@test.edu") + project = client.post("/api/projects", json={"name": "Unlimited"}).json() + corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json() + construct = any_construct(client) + for _ in range(3): + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + assert resp.status_code == 201, resp.json() + wait_for_job(client, resp.json()["id"]) + + +# ----------------------------------------------------------------- retention +def test_anonymous_corpus_removed_after_run_and_rerun_gets_410(client): + from pathlib import Path + + project = client.post("/api/projects", json={"name": "Ephemeral"}).json() + corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json() + construct = any_construct(client) + + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + job = wait_for_job(client, resp.json()["id"]) + assert job["status"] == "completed" + + results = client.get(f"/api/jobs/{job['id']}/results").json() + codes = [w["code"] for w in results["summary"]["warnings"]] + assert "ANONYMOUS_DATA_REMOVED" in codes + assert results["metadata"]["anonymous_corpus_removed"] is True + # results are still downloadable; the raw upload is gone + assert client.get(f"/api/jobs/{job['id']}/export").status_code == 200 + + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + assert resp.status_code == 410 + + +def test_signed_in_corpus_survives_run(client): + register(client, "keeper@test.edu") + project = client.post("/api/projects", json={"name": "Kept"}).json() + corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json() + construct = any_construct(client) + + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + job = wait_for_job(client, resp.json()["id"]) + assert job["status"] == "completed" + codes = [w["code"] for w in client.get(f"/api/jobs/{job['id']}/results").json()["summary"]["warnings"]] + assert "ANONYMOUS_DATA_REMOVED" not in codes + + # re-running the same corpus works: the file is still there + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + assert resp.status_code == 201 + + +def test_ttl_purge_removes_only_expired_anonymous_projects(client, monkeypatch): + from app.db import SessionLocal + from app.models import Project + from app.retention import purge_expired_anonymous + + monkeypatch.setenv("CCR_ANON_TTL_HOURS", "24") + old_anon = client.post("/api/projects", json={"name": "OldAnon"}).json() + fresh_anon = client.post("/api/projects", json={"name": "FreshAnon"}).json() + register(client, "owner@test.edu") + owned = client.post("/api/projects", json={"name": "OwnedOld"}).json() + + db = SessionLocal() + try: + db.get(Project, old_anon["id"]).created_at = "2020-01-01T00:00:00+00:00" + db.get(Project, owned["id"]).created_at = "2020-01-01T00:00:00+00:00" + db.commit() + purged = purge_expired_anonymous(db) + assert purged == 1 + assert db.get(Project, old_anon["id"]) is None + assert db.get(Project, fresh_anon["id"]) is not None + assert db.get(Project, owned["id"]) is not None # owned data never TTL-purged + finally: + db.close() + + +# ------------------------------------------------------------ saved-run cap +def test_saved_run_cap_for_signed_in_users(client, monkeypatch): + monkeypatch.setenv("CCR_USER_MAX_SAVED_RUNS", "2") + register(client, "capped@test.edu") + project = client.post("/api/projects", json={"name": "Capped"}).json() + corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json() + construct = any_construct(client) + + for _ in range(2): + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + assert resp.status_code == 201 + wait_for_job(client, resp.json()["id"]) + + resp = run_job(client, project["id"], corpus["id"], construct["id"]) + assert resp.status_code == 409 + assert "saved runs" in resp.json()["detail"] + + +# ---------------------------------------------------------------- ownership +def test_owned_projects_invisible_and_untouchable_to_others(client): + register(client, "alice@test.edu", "Alice") + owned = client.post("/api/projects", json={"name": "AlicePrivate"}).json() + client.post("/api/auth/logout") + + ids = [p["id"] for p in client.get("/api/projects").json()] + assert owned["id"] not in ids # invisible to anonymous viewers + assert client.patch(f"/api/projects/{owned['id']}", json={"archived": True}).status_code == 403 + assert client.delete(f"/api/projects/{owned['id']}").status_code == 403 + + register(client, "bob@test.edu", "Bob") + assert client.patch(f"/api/projects/{owned['id']}", json={"archived": True}).status_code == 403 + + +# ------------------------------------------------- construct file upload +def test_parse_construct_file_with_reverse_column(client): + csv = "item,reverse\nI am satisfied with my life.,0\nI rarely feel content. ,1\n,\nI am satisfied with my life.,0\n" + resp = client.post( + "/api/constructs/parse-file", + files={"file": ("swls_short.csv", io.BytesIO(csv.encode()), "text/csv")}, + ) + assert resp.status_code == 200, resp.json() + body = resp.json() + assert body["items"] == [ + {"text": "I am satisfied with my life.", "reverse_scored": False}, + {"text": "I rarely feel content.", "reverse_scored": True}, + ] + assert any("blank" in w for w in body["warnings"]) + assert any("duplicate" in w for w in body["warnings"]) + assert body["suggested_name"] == "Swls Short" + + +def test_parse_construct_file_with_r_marker_single_column(client): + csv = "text\nLife feels meaningful to me\nNothing I do matters (R)\n" + resp = client.post( + "/api/constructs/parse-file", + files={"file": ("meaning.csv", io.BytesIO(csv.encode()), "text/csv")}, + ) + body = resp.json() + assert body["items"][1] == {"text": "Nothing I do matters", "reverse_scored": True} + + +def test_parse_construct_file_rejects_bad_type_and_empty(client): + resp = client.post( + "/api/constructs/parse-file", + files={"file": ("items.pdf", io.BytesIO(b"x"), "application/pdf")}, + ) + assert resp.status_code == 400 + resp = client.post( + "/api/constructs/parse-file", + files={"file": ("empty.csv", io.BytesIO(b"item\n"), "text/csv")}, + ) + assert resp.status_code == 400 + + +# ------------------------------------------------------- perf: dedup encode +def test_encode_unique_matches_full_encode_and_saves_calls(): + import numpy as np + + from app.ccr import HashEmbeddingBackend, encode_unique + + class Counting(HashEmbeddingBackend): + def __init__(self): + super().__init__() + self.n_encoded = 0 + + def encode(self, texts, progress_cb=None): + self.n_encoded += len(texts) + return super().encode(texts, progress_cb) + + texts = ["alpha beta", "gamma delta", "alpha beta", "alpha beta", "gamma delta"] + counting = Counting() + deduped = encode_unique(counting, texts) + assert counting.n_encoded == 2 # only unique texts hit the encoder + full = HashEmbeddingBackend().encode(texts) + assert np.allclose(deduped, full) # bit-identical expansion + + +def test_created_construct_carries_reverse_flags_into_run_metadata(client): + created = client.post( + "/api/constructs", + json={ + "name": "Flagged Scale", + "items": ["good item", "bad item"], + "reverse_scored": [False, True], + }, + ).json() + assert created["reverse_scored"] == [False, True] + + project = client.post("/api/projects", json={"name": "FlagRun"}).json() + corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json() + resp = run_job(client, project["id"], corpus["id"], created["id"]) + job = wait_for_job(client, resp.json()["id"]) + snapshot = client.get(f"/api/jobs/{job['id']}/results").json()["metadata"]["construct_snapshot"] + assert snapshot["items"][1]["reverse_scored"] is True diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6dbfa9883c7350fca597b6358810ddb2197900a7..24db6a29bee44f61cc211f362d24d1d2b26d2864 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,8 +1,8 @@ """End-to-end API tests: project -> upload -> job -> results -> export. Run against the fake embedding backend (model_name = 'fake-deterministic'), -exercising the full pipeline — tolerant upload parsing, the real job queue -(worker thread + polling), results summary, warnings, and export shape — +exercising the full pipeline - tolerant upload parsing, the real job queue +(worker thread + polling), results summary, warnings, and export shape - without ML dependencies. """ @@ -81,7 +81,7 @@ def test_health(client): def test_seed_constructs_present(client): names = {c["name"] for c in client.get("/api/constructs").json()} assert "Satisfaction with Life" in names - assert "Moral Foundations — Care" in names + assert "Moral Foundations - Care" in names def test_corpus_upload_parses_columns(flow): @@ -103,14 +103,24 @@ def test_results_summary_and_warnings(client, flow): assert summary["n_docs"] == 5 # empty row dropped assert summary["n_dropped_empty"] == 1 assert len(summary["item_means"]) == 5 # SWLS has 5 items - assert any("duplicate" in w for w in summary["warnings"]) - assert any("empty" in w for w in summary["warnings"]) + codes = {w["code"] for w in summary["warnings"]} # structured objects (spec 0001) + assert "DUPLICATE_TEXTS" in codes + assert "EMPTY_ROWS_DROPPED" in codes + assert all({"code", "severity", "message"} <= set(w) for w in summary["warnings"]) # satisfaction-flavored texts should outrank the bus/printer rows top_texts = " ".join(d["text"] for d in summary["top_docs"][:2]) assert "satisfied" in top_texts or "ideal" in top_texts assert metadata["construct"] == "Satisfaction with Life" assert metadata["model"] == "fake-deterministic" assert metadata["corpus_parse_info"]["format"] == "csv" + # spec 0001/0004 metadata additions + assert metadata["output_schema_version"] == "1.0" + assert metadata["scoring"]["adjustment_strategy"] == "none" + assert metadata["language"]["selected"] == "en" + snapshot = metadata["construct_snapshot"] + assert snapshot["construct_id"] == "satisfaction_with_life" + assert snapshot["item_hash"] and len(snapshot["items"]) == 5 + assert "ccr_score" in metadata["output_schema"] def test_export_csv_shape(client, flow): @@ -163,3 +173,99 @@ def test_validation_errors(client, flow): # bad file type resp = upload(client, flow["project"]["id"], "evil.exe", b"x") assert resp.status_code == 400 + # unknown model id (registry-validated now) + resp = client.post( + "/api/jobs", + json={ + "project_id": flow["project"]["id"], + "corpus_id": flow["corpus"]["id"], + "construct_id": flow["construct"]["id"], + "text_column": "text", + "model_name": "sentence-transformers/all-MiniLM-L6-v2", # provider id, not registry id + }, + ) + assert resp.status_code == 400 + + +def test_projects_carry_activity_and_sort_by_it(client, flow): + projects = client.get("/api/projects").json() + demo = next(p for p in projects if p["id"] == flow["project"]["id"]) + assert demo["n_runs"] >= 1 + assert demo["last_activity_at"] >= demo["created_at"] + # the project with runs sorts above a freshly created empty one from earlier tests + order = [p["last_activity_at"] for p in projects] + assert order == sorted(order, reverse=True) + + +# --------------------------------------------------- spec 0003: models API +def test_models_endpoint_from_registry(client): + models = client.get("/api/models").json() + ids = [m["id"] for m in models] + assert "all-minilm-l6-v2" in ids and "e5-large-v2" in ids and "multilingual-e5-base" in ids + defaults = [m for m in models if m["default"]] + assert len(defaults) == 1 and defaults[0]["id"] == "all-minilm-l6-v2" + assert client.get("/api/languages").json()[0] == "en" + + +# --------------------------------------------------- spec 0004: construct library +def test_constructs_carry_library_fields(client): + swls = next( + c for c in client.get("/api/constructs").json() + if c["name"] == "Satisfaction with Life" + ) + assert swls["verification_status"] == "needs_verification" + assert swls["version"] == 1 + assert len(swls["item_hash"]) == 16 + assert swls["reverse_scored"] == [False] * 5 + + +# --------------------------------------------------- spec 0001: language warnings +def test_language_uncertain_on_tiny_corpus(client, flow): + body = client.get(f"/api/jobs/{flow['job']['id']}/results").json() + codes = {w["code"] for w in body["summary"]["warnings"]} + # 5-row corpus is far below the 20 detectable-row minimum + assert "LANGUAGE_UNCERTAIN" in codes + assert body["metadata"]["language"]["detected"] is None + + +def test_short_text_and_model_language_warnings(client, flow): + csv = "text\n" + "\n".join( + [f"esta es una frase de prueba número {i} para el corpus" for i in range(25)] + + ["si", "no"] # two very short rows + ) + corpus = upload(client, flow["project"]["id"], "spanish.csv", csv.encode()).json() + job = client.post( + "/api/jobs", + json={ + "project_id": flow["project"]["id"], + "corpus_id": corpus["id"], + "construct_id": flow["construct"]["id"], + "text_column": "text", + "model_name": "fake-deterministic", + "language": "es", + }, + ).json() + job = wait_for_job(client, job["id"]) + assert job["status"] == "completed", job["error"] + body = client.get(f"/api/jobs/{job['id']}/results").json() + warnings = {w["code"]: w for w in body["summary"]["warnings"]} + assert warnings["TEXT_TOO_SHORT"]["count"] == 2 + assert warnings["TEXT_TOO_SHORT"]["affected_rows_sample"] + assert body["metadata"]["language"]["selected"] == "es" + + +# --------------------------------------------------- spec 0002: script export +def test_script_export_is_valid_offline_python(client, flow): + job_id = flow["job"]["id"] + resp = client.get(f"/api/jobs/{job_id}/script") + assert resp.status_code == 200 + source = resp.text + compile(source, "reproduce_analysis.py", "exec") # must be valid Python + # embeds the construct items verbatim and never references the platform + assert "In most ways my life is close to my ideal." in source + assert "sim_item_" in source and "ccr_score" in source + assert "127.0.0.1" not in source and "/api/" not in source + + reqs = client.get(f"/api/jobs/{job_id}/script-requirements") + assert reqs.status_code == 200 + assert "==" in reqs.text # pinned versions, not ranges diff --git a/backend/tests/test_auth_tiers_and_lifecycle.py b/backend/tests/test_auth_tiers_and_lifecycle.py new file mode 100644 index 0000000000000000000000000000000000000000..1a5e9878b867ba719bd78fbc1daee7ed3dce3b58 --- /dev/null +++ b/backend/tests/test_auth_tiers_and_lifecycle.py @@ -0,0 +1,138 @@ +"""Sign-in tiers (anonymous caps lift on sign-in) and project lifecycle +(archive is reversible; delete cascades to files and rows).""" + +import io +import time +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +def upload(client, project_id, name, payload: bytes): + return client.post( + f"/api/projects/{project_id}/corpora", + files={"file": (name, io.BytesIO(payload), "text/csv")}, + ) + + +def wait_for_job(client, job_id, timeout=10.0): + deadline = time.time() + timeout + while time.time() < deadline: + job = client.get(f"/api/jobs/{job_id}").json() + if job["status"] in ("completed", "failed"): + return job + time.sleep(0.05) + raise TimeoutError(job_id) + + +@pytest.fixture() +def client(): + with TestClient(app) as c: + yield c + + +def csv_rows(n: int) -> bytes: + return ("text\n" + "\n".join(f"sample sentence number {i} here" for i in range(n))).encode() + + +# ------------------------------------------------------------------- tiers +def test_anonymous_row_cap_and_signin_lifts_it(client, monkeypatch): + monkeypatch.setenv("CCR_ANON_MAX_ROWS", "5") + project = client.post("/api/projects", json={"name": "Tiers"}).json() + + me = client.get("/api/auth/me").json() + assert me["signed_in"] is False and me["limits"]["max_rows"] == 5 + + resp = upload(client, project["id"], "big.csv", csv_rows(10)) + assert resp.status_code == 400 + assert "Sign in" in resp.json()["detail"] + + resp = client.post( + "/api/auth/register", + json={"email": "deva@test.edu", "password": "password123", "name": "Deva"}, + ) + assert resp.status_code == 201 + me = client.get("/api/auth/me").json() + assert me["signed_in"] is True and me["name"] == "Deva" + + resp = upload(client, project["id"], "big.csv", csv_rows(10)) + assert resp.status_code == 201, resp.json() + + client.post("/api/auth/logout") + assert client.get("/api/auth/me").json()["signed_in"] is False + + +def test_anonymous_size_cap(client, monkeypatch): + monkeypatch.setenv("CCR_ANON_MAX_BYTES", "200") + project = client.post("/api/projects", json={"name": "SizeCap"}).json() + resp = upload(client, project["id"], "big.csv", csv_rows(50)) + assert resp.status_code == 413 + assert "Sign in" in resp.json()["detail"] + + +def test_tampered_session_cookie_is_anonymous(client): + client.cookies.set("ccr_session", "aGFja2Vy.badsignature") + assert client.get("/api/auth/me").json()["signed_in"] is False + + +# --------------------------------------------------------------- lifecycle +def test_archive_toggle_is_reversible(client): + project = client.post("/api/projects", json={"name": "Archivable"}).json() + assert project["archived"] is False + + patched = client.patch(f"/api/projects/{project['id']}", json={"archived": True}).json() + assert patched["archived"] is True + listed = next(p for p in client.get("/api/projects").json() if p["id"] == project["id"]) + assert listed["archived"] is True + + patched = client.patch(f"/api/projects/{project['id']}", json={"archived": False}).json() + assert patched["archived"] is False + + +def test_delete_cascades_rows_and_files(client): + project = client.post("/api/projects", json={"name": "Doomed"}).json() + corpus = upload(client, project["id"], "corpus.csv", csv_rows(6)).json() + + constructs = client.get("/api/constructs").json() + swls = next(c for c in constructs if c["name"] == "Satisfaction with Life") + job = client.post( + "/api/jobs", + json={ + "project_id": project["id"], + "corpus_id": corpus["id"], + "construct_id": swls["id"], + "text_column": "text", + "model_name": "fake-deterministic", + }, + ).json() + job = wait_for_job(client, job["id"]) + assert job["status"] == "completed" + + # capture file paths before deletion + results = client.get(f"/api/jobs/{job['id']}/results") + assert results.status_code == 200 + + resp = client.delete(f"/api/projects/{project['id']}") + assert resp.status_code == 204 + + assert client.get(f"/api/jobs/{job['id']}").status_code == 404 + assert all(p["id"] != project["id"] for p in client.get("/api/projects").json()) + # corpora listing for the deleted project 404s + assert client.get(f"/api/projects/{project['id']}/corpora").status_code == 404 + + +def test_delete_removes_files_on_disk(client, tmp_path): + import os + + data_dir = Path(os.environ["CCR_DATA_DIR"]) + project = client.post("/api/projects", json={"name": "FileCheck"}).json() + before = set((data_dir / "corpora").glob("*")) + upload(client, project["id"], "corpus.csv", csv_rows(6)) + created = set((data_dir / "corpora").glob("*")) - before + assert len(created) == 1 + + client.delete(f"/api/projects/{project['id']}") + assert not created.pop().exists() diff --git a/backend/tests/test_auto_migration.py b/backend/tests/test_auto_migration.py new file mode 100644 index 0000000000000000000000000000000000000000..48e131bee1d9209fe62f10af4555faae4fec45ce --- /dev/null +++ b/backend/tests/test_auto_migration.py @@ -0,0 +1,42 @@ +"""Additive SQLite auto-migration: an old-schema DB gains new ORM columns +at startup instead of 500ing (the 'no such column: projects.archived' bug).""" + +import sqlite3 + +from sqlalchemy import create_engine, inspect + +from app.db import Base, auto_migrate_sqlite +from app.models import Project # noqa: F401 - registers tables on Base.metadata + + +def test_old_schema_gains_missing_columns(tmp_path): + db_path = tmp_path / "old.db" + + # Simulate a dev DB created before archived/language/etc. existed. + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE projects (id VARCHAR(32) PRIMARY KEY, name VARCHAR(200), " + "description TEXT, created_at VARCHAR(32))" + ) + conn.execute( + "INSERT INTO projects VALUES ('abc123', 'Old Project', '', '2026-07-01T00:00:00')" + ) + conn.commit() + conn.close() + + engine = create_engine(f"sqlite:///{db_path}") + Base.metadata.create_all(engine) # creates the other, brand-new tables + added = auto_migrate_sqlite(engine, Base.metadata) + + assert "projects.archived" in added + cols = {c["name"] for c in inspect(engine).get_columns("projects")} + assert "archived" in cols + + # Existing row survives with the default applied, and queries work. + conn = sqlite3.connect(db_path) + row = conn.execute("SELECT name, archived FROM projects WHERE id='abc123'").fetchone() + conn.close() + assert row == ("Old Project", 0) + + # Second run is a no-op (idempotent). + assert auto_migrate_sqlite(engine, Base.metadata) == [] diff --git a/backend/tests/test_ccr.py b/backend/tests/test_ccr.py index 6cf1dd8d85305546052970a05f08d1c88c31f306..225c8692e64617bf7438df6f002dd348571125cf 100644 --- a/backend/tests/test_ccr.py +++ b/backend/tests/test_ccr.py @@ -1,4 +1,4 @@ -"""Unit tests for the CCR engine (deterministic fake backend — no torch).""" +"""Unit tests for the CCR engine (deterministic fake backend - no torch).""" import numpy as np import pytest diff --git a/backend/tests/test_google_auth.py b/backend/tests/test_google_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..0f2b62e05f4f2c39f3949cdd34a61c5eef7b79c7 --- /dev/null +++ b/backend/tests/test_google_auth.py @@ -0,0 +1,89 @@ +"""Google sign-in (Supabase PKCE): feature flag, redirect flow, callback +find-or-create, and password-login guard for Google-only accounts. The +Supabase exchange itself is mocked - no network in tests.""" + +import pytest +from fastapi.testclient import TestClient + +from app import auth, auth_google +from app.main import app + + +@pytest.fixture() +def client(): + with TestClient(app) as c: + yield c + + +@pytest.fixture() +def google_env(monkeypatch): + monkeypatch.setenv("SUPABASE_URL", "https://fakeproj.supabase.co") + monkeypatch.setenv("SUPABASE_ANON_KEY", "fake-anon-key") + monkeypatch.setenv("CCR_APP_URL", "http://testserver") + + +def test_unconfigured_instance_hides_and_refuses_google(client): + assert client.get("/api/auth/me").json().get("google_available") is False + assert client.get("/api/auth/google/login", follow_redirects=False).status_code == 503 + + +def test_login_redirects_to_supabase_with_pkce(client, google_env): + me = client.get("/api/auth/me").json() + assert me["google_available"] is True + + resp = client.get("/api/auth/google/login", follow_redirects=False) + assert resp.status_code == 307 + loc = resp.headers["location"] + assert loc.startswith("https://fakeproj.supabase.co/auth/v1/authorize?") + assert "provider=google" in loc + assert "code_challenge=" in loc and "code_challenge_method=s256" in loc + assert "redirect_to=http%3A%2F%2Ftestserver%2Fapi%2Fauth%2Fgoogle%2Fcallback" in loc + assert auth_google.VERIFIER_COOKIE in resp.cookies + + +def test_callback_creates_user_and_signs_in(client, google_env, monkeypatch): + monkeypatch.setattr( + auth_google, "exchange", + lambda code, verifier: {"email": "pi@lab.edu", "name": "The PI"}, + ) + client.cookies.set(auth_google.VERIFIER_COOKIE, auth.sign_payload({"v": "verifier123"})) + + resp = client.get("/api/auth/google/callback?code=abc", follow_redirects=False) + assert resp.status_code == 307 and resp.headers["location"] == "/" + + me = client.get("/api/auth/me").json() + assert me["signed_in"] is True and me["email"] == "pi@lab.edu" and me["name"] == "The PI" + + # second sign-in reuses the same account (no duplicate users) + client.cookies.set(auth_google.VERIFIER_COOKIE, auth.sign_payload({"v": "verifier456"})) + client.get("/api/auth/google/callback?code=def", follow_redirects=False) + from app.db import SessionLocal + from app.models import User + + db = SessionLocal() + try: + assert db.query(User).filter_by(email="pi@lab.edu").count() == 1 + finally: + db.close() + + +def test_callback_without_verifier_fails_safely(client, google_env): + resp = client.get("/api/auth/google/callback?code=abc", follow_redirects=False) + assert resp.status_code == 307 + assert "auth_error=" in resp.headers["location"] + + +def test_google_only_account_cannot_password_login(client, google_env, monkeypatch): + monkeypatch.setattr( + auth_google, "exchange", + lambda code, verifier: {"email": "gonly@lab.edu", "name": "G Only"}, + ) + client.cookies.set(auth_google.VERIFIER_COOKIE, auth.sign_payload({"v": "v1"})) + client.get("/api/auth/google/callback?code=abc", follow_redirects=False) + client.post("/api/auth/logout") + + resp = client.post( + "/api/auth/login", json={"email": "gonly@lab.edu", "password": "password123"} + ) + assert resp.status_code == 401 + assert "Google" in resp.json()["detail"] diff --git a/backend/tests/test_registry_and_prefixes.py b/backend/tests/test_registry_and_prefixes.py new file mode 100644 index 0000000000000000000000000000000000000000..c0fe8aefcefc5c8b98df103f07f6be56e2444a45 --- /dev/null +++ b/backend/tests/test_registry_and_prefixes.py @@ -0,0 +1,110 @@ +"""Registry loader + E5 prefix application (spec 0003) and warnings engine units (spec 0001).""" + +import numpy as np +import pytest + +from app import registry +from app.ccr import run_ccr +from app.warnings_engine import ( + detect_corpus_language, + model_language_warning, + short_text_warning, +) + + +class RecordingBackend: + """Captures exactly what the engine sends to the encoder.""" + + name = "recording" + + def __init__(self): + self.calls: list[list[str]] = [] + + def encode(self, texts, progress_cb=None): + self.calls.append(list(texts)) + rng = np.random.default_rng(42) + emb = rng.normal(size=(len(texts), 8)) + return emb / np.linalg.norm(emb, axis=1, keepdims=True) + + +# ------------------------------------------------------------------ registry +def test_registry_loads_three_models_with_minilm_default(): + models = registry.list_models() + ids = [m.id for m in models] + assert ids[0] == "all-minilm-l6-v2" # default sorts first + assert registry.default_model().id == "all-minilm-l6-v2" + assert {"e5-large-v2", "multilingual-e5-base"} <= set(ids) + + +def test_e5_config_requires_prefix_on_both_sides(): + cfg = registry.get_model("e5-large-v2") + assert cfg.requires_prefix + assert cfg.item_prefix == "query: " and cfg.text_prefix == "query: " + assert cfg.max_seq_length == 512 and cfg.embedding_dimension == 1024 + + +def test_multilingual_language_set_resolves_to_real_codes(): + cfg = registry.get_model("multilingual-e5-base") + assert cfg.language_set_name == "xlm_roberta_100" + assert "es" in cfg.supported_languages and "sw" in cfg.supported_languages + assert cfg.supports_language("hi") and not cfg.supports_language("xx") + + +def test_unknown_model_raises(): + with pytest.raises(KeyError): + registry.get_model("gpt-9000") + + +# ------------------------------------------------------------------ prefixes +def test_run_ccr_applies_prefixes_to_encoder_input_only(): + backend = RecordingBackend() + result = run_ccr( + ["first text here", "second text here"], + ["an item statement"], + backend, + item_prefix="query: ", + text_prefix="query: ", + ) + item_call, text_call = backend.calls + assert item_call == ["query: an item statement"] + assert text_call == ["query: first text here", "query: second text here"] + # metadata records the prefixes for the reproducibility bundle + assert result.metadata["item_prefix"] == "query: " + assert result.metadata["text_prefix"] == "query: " + + +def test_run_ccr_without_prefixes_passes_raw_strings(): + backend = RecordingBackend() + run_ccr(["a text"], ["an item"], backend) + assert backend.calls[0] == ["an item"] and backend.calls[1] == ["a text"] + + +# ------------------------------------------------------------- warnings units +def test_short_text_warning_boundary(): + w = short_text_warning(["one two three", "one two three four", "x"]) + assert w["count"] == 2 # 3-token and 1-token rows flagged; 4-token row not + assert w["affected_rows_sample"] == [0, 2] + assert short_text_warning(["four token sentence here"] * 3) is None + + +def test_language_detection_uncertain_below_min_rows(): + result, warnings = detect_corpus_language(["hello there my good friend"] * 5, "en") + assert result.detected is None + assert warnings[0]["code"] == "LANGUAGE_UNCERTAIN" + + +def test_language_mismatch_detected_deterministically(): + spanish = [f"esta es una frase de prueba número {i} sobre la vida cotidiana" for i in range(30)] + result, warnings = detect_corpus_language(spanish, "en") + assert result.detected == "es" + assert any(w["code"] == "LANGUAGE_MISMATCH" for w in warnings) + # determinism: same corpus, same outcome + result2, _ = detect_corpus_language(spanish, "en") + assert result2.detected == result.detected and result2.confidence == result.confidence + + +def test_model_language_unsupported_warning(): + w = model_language_warning("xx", "multilingual-e5-base", frozenset({"en", "es"}), "demo_set") + assert w["code"] == "MODEL_LANGUAGE_UNSUPPORTED" + assert model_language_warning("en", "m", frozenset({"en"}), None) is None + assert model_language_warning("zz", "m", frozenset(), None) is None # unknown coverage: no warning diff --git a/backend/tests/test_storage_backends.py b/backend/tests/test_storage_backends.py new file mode 100644 index 0000000000000000000000000000000000000000..cc7a54cfafd089f7cd136b03368813911e93fe4e --- /dev/null +++ b/backend/tests/test_storage_backends.py @@ -0,0 +1,154 @@ +"""Storage interface: local backend (default) and S3/R2 backend via a fake +client - proves production object storage works end to end (upload -> run -> +export -> retention delete) without any network or boto3 dependency.""" + +import io +import time + +import pytest +from fastapi.testclient import TestClient + +from app import storage +from app.main import app + + +class FakeS3: + """Minimal S3 client: just what storage.py calls.""" + + def __init__(self): + self.objects: dict[str, bytes] = {} + + def put_object(self, Bucket, Key, Body): + self.objects[Key] = Body if isinstance(Body, bytes) else Body.read() + + def head_object(self, Bucket, Key): + if Key not in self.objects: + raise KeyError(Key) + return {"ContentLength": len(self.objects[Key])} + + def download_file(self, Bucket, Key, Filename): + with open(Filename, "wb") as f: + f.write(self.objects[Key]) + + def get_object(self, Bucket, Key): + return {"Body": io.BytesIO(self.objects[Key])} + + def delete_object(self, Bucket, Key): + self.objects.pop(Key, None) + + +@pytest.fixture() +def s3(monkeypatch): + fake = FakeS3() + monkeypatch.setenv("CCR_STORAGE", "s3") + monkeypatch.setenv("CCR_S3_BUCKET", "ccr-test") + monkeypatch.setattr(storage, "_client", fake) + yield fake + monkeypatch.setattr(storage, "_client", None) + + +@pytest.fixture() +def client(): + with TestClient(app) as c: + yield c + + +def csv_rows(n: int) -> bytes: + return ("text\n" + "\n".join(f"sample sentence number {i} here" for i in range(n))).encode() + + +def wait_for_job(client, job_id, timeout=10.0): + deadline = time.time() + timeout + while time.time() < deadline: + job = client.get(f"/api/jobs/{job_id}").json() + if job["status"] in ("completed", "failed"): + return job + time.sleep(0.05) + raise TimeoutError(job_id) + + +# ------------------------------------------------------------------ unit +def test_s3_roundtrip(s3, tmp_path): + locator = storage.store_bytes("corpora", "abc.csv", b"text\nhello world row\n") + assert locator == "s3://corpora/abc.csv" + assert storage.exists(locator) + + local, is_temp = storage.fetch_to_local(locator) + assert is_temp and local.read_bytes().startswith(b"text") + local.unlink() + + assert b"".join(storage.open_stream(locator)) == b"text\nhello world row\n" + storage.delete(locator) + assert not storage.exists(locator) + assert s3.objects == {} + + +def test_local_backend_unchanged(tmp_path): + locator = storage.store_bytes("corpora", "local_check.csv", b"data") + assert not storage.is_s3(locator) + local, is_temp = storage.fetch_to_local(locator) + assert not is_temp and local.read_bytes() == b"data" + storage.delete(locator) + assert not storage.exists(locator) + + +# ------------------------------------------------------------ end to end +def test_full_flow_on_s3_backend(client, s3): + """Signed-in upload -> corpus lands in the bucket -> run materializes a + temp copy -> result CSV lands in the bucket -> export streams it -> + project delete empties the bucket.""" + client.post( + "/api/auth/register", + json={"email": "s3user@test.edu", "password": "password123", "name": "S3"}, + ) + project = client.post("/api/projects", json={"name": "S3Flow"}).json() + corpus = client.post( + f"/api/projects/{project['id']}/corpora", + files={"file": ("c.csv", io.BytesIO(csv_rows(6)), "text/csv")}, + ).json() + assert any(k.startswith("corpora/") for k in s3.objects) + + construct = client.get("/api/constructs").json()[0] + job = client.post( + "/api/jobs", + json={ + "project_id": project["id"], + "corpus_id": corpus["id"], + "construct_id": construct["id"], + "text_column": "text", + "model_name": "fake-deterministic", + }, + ).json() + job = wait_for_job(client, job["id"]) + assert job["status"] == "completed" + assert any(k.startswith("results/") for k in s3.objects) + + export = client.get(f"/api/jobs/{job['id']}/export") + assert export.status_code == 200 + assert b"ccr_score" in export.content + + client.delete(f"/api/projects/{project['id']}") + assert s3.objects == {} # cascade emptied the bucket + + +def test_anonymous_run_deletes_s3_corpus(client, s3): + project = client.post("/api/projects", json={"name": "S3Anon"}).json() + corpus = client.post( + f"/api/projects/{project['id']}/corpora", + files={"file": ("c.csv", io.BytesIO(csv_rows(5)), "text/csv")}, + ).json() + construct = client.get("/api/constructs").json()[0] + job = client.post( + "/api/jobs", + json={ + "project_id": project["id"], + "corpus_id": corpus["id"], + "construct_id": construct["id"], + "text_column": "text", + "model_name": "fake-deterministic", + }, + ).json() + job = wait_for_job(client, job["id"]) + assert job["status"] == "completed" + assert not any(k.startswith("corpora/") for k in s3.objects) # upload gone + assert any(k.startswith("results/") for k in s3.objects) # results kept for TTL diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e28f0dabbbe0ae8f5b5d05e610dbdf4bfd2f306b..29ed6e29dbae29437e76445434256f9a42044dc1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,20 +2,114 @@ import { useEffect, useState } from "react"; import { api } from "./api.js"; import Workspace from "./Workspace.jsx"; +function relativeTime(iso) { + if (!iso) return ""; + const then = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z"); + const mins = Math.max(0, Math.floor((Date.now() - then.getTime()) / 60000)); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return then.toISOString().slice(0, 10); +} + +function groupProjects(projects) { + // Buckets by last activity: Today / This week / Earlier, with archived + // projects collapsed into their own group at the bottom. Projects arrive + // sorted by last activity (backend), so group order falls out naturally. + const now = Date.now(); + const DAY = 86400000; + const groups = { Today: [], "This week": [], Earlier: [], Archived: [] }; + for (const p of projects) { + if (p.archived) { + groups.Archived.push(p); + continue; + } + const iso = p.last_activity_at || p.created_at; + const t = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z").getTime(); + const age = now - t; + if (age < DAY) groups.Today.push(p); + else if (age < 7 * DAY) groups["This week"].push(p); + else groups.Earlier.push(p); + } + return Object.entries(groups).filter(([, items]) => items.length > 0); +} + export default function App() { const [projects, setProjects] = useState([]); const [selectedId, setSelectedId] = useState(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(""); + const [filter, setFilter] = useState(""); const [error, setError] = useState(""); + const [auth, setAuth] = useState(null); + const [showLogin, setShowLogin] = useState(false); + const [authMode, setAuthMode] = useState("signin"); // signin | register + const [authEmail, setAuthEmail] = useState(""); + const [authPassword, setAuthPassword] = useState(""); + const [authName, setAuthName] = useState(""); + const [authError, setAuthError] = useState(""); + const [authBusy, setAuthBusy] = useState(false); const loadProjects = () => api.listProjects().then(setProjects).catch((e) => setError(e.message)); + const loadAuth = () => api.authMe().then(setAuth).catch(() => {}); useEffect(() => { loadProjects(); + loadAuth(); + // Surface Google sign-in failures passed back via redirect. + const params = new URLSearchParams(window.location.search); + const authFail = params.get("auth_error"); + if (authFail) { + setError(`Sign-in problem: ${authFail.replaceAll("-", " ")}.`); + window.history.replaceState({}, "", "/"); + } }, []); + async function handleAuthSubmit(e) { + e.preventDefault(); + setAuthError(""); + setAuthBusy(true); + try { + if (authMode === "register") { + await api.register({ email: authEmail.trim(), password: authPassword, name: authName.trim() }); + } else { + await api.login({ email: authEmail.trim(), password: authPassword }); + } + setShowLogin(false); + setAuthEmail(""); + setAuthPassword(""); + setAuthName(""); + await Promise.all([loadAuth(), loadProjects()]); // owned projects appear on sign-in + } catch (err) { + setAuthError(err.message); + } finally { + setAuthBusy(false); + } + } + + async function handleLogout() { + try { + await api.logout(); + await Promise.all([loadAuth(), loadProjects()]); + } catch (err) { + setError(err.message); + } + } + + useEffect(() => { + if (projects.length === 0) { + setSelectedId(null); + return; + } + if (!selectedId || !projects.some((p) => p.id === selectedId)) { + setSelectedId(projects[0].id); + } + }, [projects, selectedId]); + async function createProject(e) { e.preventDefault(); if (!newName.trim()) return; @@ -31,6 +125,10 @@ export default function App() { } const selected = projects.find((p) => p.id === selectedId) || null; + const normalizedFilter = filter.trim().toLowerCase(); + const visibleProjects = projects.filter((p) => + p.name.toLowerCase().includes(normalizedFilter) + ); return (
@@ -39,45 +137,183 @@ export default function App() { Contextualized Construct Representations · theory-driven psychological text analysis - - -
-
+
+ )} + +
+
@@ -87,7 +323,17 @@ export default function App() {
)} {selected ? ( - + { + setSelectedId(null); + loadProjects(); + }} + /> ) : (

Welcome

@@ -97,7 +343,7 @@ export default function App() { score distributions, and a reproducibility record for every run.

- Self-contained by design: embeddings run on this server itself — no + Self-contained by design: embeddings run on this server itself - no third-party AI APIs. Demo instance: storage is ephemeral and may reset; please don't upload sensitive or identifiable data.

diff --git a/frontend/src/ConstructPicker.jsx b/frontend/src/ConstructPicker.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d6626a7f866f62ba1d1e31a4f94fb5bcbeac0f69 --- /dev/null +++ b/frontend/src/ConstructPicker.jsx @@ -0,0 +1,206 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +// Searchable, grouped construct picker. A flat dropdown stops working past +// ~15 options; the library is at 99 and growing. Researchers either know the +// scale ("GAD-7") or the family ("empathy"), so search matches name, category, +// and questionnaire, results group by category, and the last 5 used constructs +// stay on top (researchers re-run the same scales constantly). +// Keyboard: ArrowUp/Down move, Enter selects, Escape closes. + +const RECENT_KEY = "ccr_recent_constructs"; +const RECENT_MAX = 5; + +function readRecent() { + try { + return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); + } catch { + return []; + } +} + +export function rememberRecent(id) { + const next = [id, ...readRecent().filter((x) => x !== id)].slice(0, RECENT_MAX); + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(next)); + } catch { + /* storage unavailable: recents simply don't persist */ + } +} + +export default function ConstructPicker({ constructs, value, onChange }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const rootRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + const selected = constructs.find((c) => c.id === value) || null; + + const groups = useMemo(() => { + const q = query.trim().toLowerCase(); + const match = (c) => + !q || + c.name.toLowerCase().includes(q) || + (c.category || "").toLowerCase().includes(q); + const filtered = constructs.filter(match); + + const out = []; + const used = new Set(); + + const recentIds = readRecent(); + const recent = recentIds + .map((id) => filtered.find((c) => c.id === id)) + .filter(Boolean); + if (recent.length) { + out.push(["Recently used", recent]); + recent.forEach((c) => used.add(c.id)); + } + + const custom = filtered.filter((c) => !c.is_seed && !used.has(c.id)); + if (custom.length) { + out.push(["My custom constructs", custom]); + custom.forEach((c) => used.add(c.id)); + } + + const byCategory = new Map(); + for (const c of filtered) { + if (used.has(c.id)) continue; + const cat = c.category || "Other"; + if (!byCategory.has(cat)) byCategory.set(cat, []); + byCategory.get(cat).push(c); + } + for (const cat of [...byCategory.keys()].sort((a, b) => a.localeCompare(b))) { + out.push([cat, byCategory.get(cat).sort((a, b) => a.name.localeCompare(b.name))]); + } + return out; + }, [constructs, query]); + + const flat = useMemo(() => groups.flatMap(([, items]) => items), [groups]); + + useEffect(() => setActive(0), [query, open]); + + // Close on outside click. + useEffect(() => { + if (!open) return undefined; + const onDown = (e) => { + if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open]); + + // Keep the active option scrolled into view. + useEffect(() => { + const el = listRef.current?.querySelector('[data-active="true"]'); + el?.scrollIntoView({ block: "nearest" }); + }, [active, open]); + + function choose(c) { + onChange(c.id); + rememberRecent(c.id); + setOpen(false); + setQuery(""); + } + + function onKeyDown(e) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((a) => Math.min(a + 1, flat.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((a) => Math.max(a - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (flat[active]) choose(flat[active]); + } else if (e.key === "Escape") { + setOpen(false); + } + } + + let index = -1; // running index across groups for keyboard highlight + + return ( +
+ {!open ? ( + + ) : ( + <> + setQuery(e.target.value)} + onKeyDown={onKeyDown} + /> +
+ {flat.length === 0 && ( +

+ No constructs match "{query}". Try a scale abbreviation or use + Custom construct. +

+ )} + {groups.map(([label, items]) => ( +
+
{label}
+ {items.map((c) => { + index += 1; + const isActive = index === active; + return ( +
{ + e.preventDefault(); + choose(c); + }} + > + {c.name} + + {c.category ? `${c.category} · ` : ""} + {c.items.length} item{c.items.length === 1 ? "" : "s"} + {c.verification_status !== "verified" ? " · unverified" : ""} + +
+ ); + })} +
+ ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/ResultsView.jsx b/frontend/src/ResultsView.jsx index 68f67253a8ab2b5f94b4f8fefb3f9079fac67886..321940d7437311207a1e0f18079c2c8c08a69630 100644 --- a/frontend/src/ResultsView.jsx +++ b/frontend/src/ResultsView.jsx @@ -25,14 +25,20 @@ export default function ResultsView({ jobId, onBack }) { return ( <> -
+
-
+
+ + + + + + @@ -64,7 +70,13 @@ export default function ResultsView({ jobId, onBack }) { Data-quality notes
    {summary.warnings.map((w, i) => ( -
  • {w}
  • +
  • + {typeof w === "string" ? w : ( + <> + {w.code} - {w.message} + + )} +
  • ))}
@@ -79,7 +91,7 @@ export default function ResultsView({ jobId, onBack }) {

Per-item mean loadings

- Mean similarity of the corpus to each scale item — a face-validity check on which + Mean similarity of the corpus to each scale item - a face-validity check on which items drive the construct signal.

{summary.item_means.map((m, i) => ( @@ -110,7 +122,7 @@ export default function ResultsView({ jobId, onBack }) {
- Reproducibility record — model: {metadata.model} (dim{" "} + Reproducibility record - model: {metadata.model} (dim{" "} {metadata.embedding_dim}) · items hash: {metadata.items_sha256_16} · text column: {metadata.text_column} · run:{" "} {metadata.started_at} → {metadata.finished_at} ({metadata.duration_seconds}s) · @@ -118,7 +130,7 @@ export default function ResultsView({ jobId, onBack }) { {metadata.sentence_transformers && ` · sentence-transformers ${metadata.sentence_transformers}`}
- Construct reference: {metadata.construct_reference || "—"} + Construct reference: {metadata.construct_reference || "-"}
@@ -136,22 +148,24 @@ function Stat({ k, v }) { function DocTable({ docs }) { return ( - - - - - - - - - {docs.map((d) => ( - - - +
+
ScoreText
{d.score.toFixed(3)}{d.text}
+ + + + - ))} - -
ScoreText
+ + + {docs.map((d) => ( + + {d.score.toFixed(3)} + {d.text} + + ))} + + +
); } diff --git a/frontend/src/Workspace.jsx b/frontend/src/Workspace.jsx index 1059437a2caf1d3a1b8b88adbb331e9312107621..21d15ba8d5bfb73004eb5f1ecb18fc0792454a7c 100644 --- a/frontend/src/Workspace.jsx +++ b/frontend/src/Workspace.jsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "./api.js"; +import ConstructPicker from "./ConstructPicker.jsx"; import ResultsView from "./ResultsView.jsx"; -export default function Workspace({ project }) { +export default function Workspace({ project, auth, onAuthRefresh, onProjectChanged, onProjectDeleted }) { const [corpora, setCorpora] = useState([]); const [constructs, setConstructs] = useState([]); const [models, setModels] = useState([]); @@ -12,13 +13,36 @@ export default function Workspace({ project }) { const [textColumn, setTextColumn] = useState(""); const [constructId, setConstructId] = useState(""); const [modelName, setModelName] = useState(""); + const [languages, setLanguages] = useState(["en"]); + const [language, setLanguage] = useState("en"); const [uploading, setUploading] = useState(false); const [running, setRunning] = useState(false); const [error, setError] = useState(""); const [showNewConstruct, setShowNewConstruct] = useState(false); const [viewJobId, setViewJobId] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleteText, setDeleteText] = useState(""); const fileRef = useRef(null); + async function toggleArchive() { + try { + await api.patchProject(project.id, { archived: !project.archived }); + onProjectChanged?.(); + } catch (err) { + setError(err.message); + } + } + + async function handleDelete() { + try { + await api.deleteProject(project.id); + setConfirmDelete(false); + onProjectDeleted?.(); + } catch (err) { + setError(err.message); + } + } + const refreshJobs = useCallback( () => api.listJobs(project.id).then(setJobs).catch(() => {}), [project.id] @@ -31,9 +55,11 @@ export default function Workspace({ project }) { .models() .then((m) => { setModels(m); - if (m.length) setModelName(m[0].name); + const def = m.find((x) => x.default) || m[0]; + if (def) setModelName(def.id); }) .catch((e) => setError(e.message)); + api.languages().then(setLanguages).catch(() => {}); refreshJobs(); }, [project.id, refreshJobs]); @@ -77,8 +103,10 @@ export default function Workspace({ project }) { construct_id: constructId, text_column: textColumn, model_name: modelName, + language, }); await refreshJobs(); + onAuthRefresh?.(); // anonymous run counter changed } catch (err) { setError(err.message); } finally { @@ -108,13 +136,78 @@ export default function Workspace({ project }) {
)} - {/* Step 1 — corpus */} + {/* Project header + actions */} +
+
+ {project.name} + {project.archived && archived} +
+
+ + +
+
+ + {confirmDelete && ( +
setConfirmDelete(false)}> +
e.stopPropagation()}> +

Delete "{project.name}"?

+

+ This permanently deletes {corpora.length} dataset{corpora.length === 1 ? "" : "s"},{" "} + {jobs.length} run{jobs.length === 1 ? "" : "s"}, and all uploaded and result files. + This cannot be undone. If you might need it later, use Archive instead. +

+ +
+ + +
+
+
+ )} + + {/* Step 1 - corpus */}

1Corpus

Upload a CSV or XLSX file, then choose the column containing the text to analyze. + {auth && !auth.signed_in && auth.limits?.max_rows && ( + <> + {" "} + Anonymous limit: {Math.round(auth.limits.max_bytes / 1048576)} MB /{" "} + {auth.limits.max_rows.toLocaleString()} rows per file; uploads are deleted + after analysis. Sign in (top right) for larger uploads and to keep your data. + + )}

@@ -134,7 +227,7 @@ export default function Workspace({ project }) {
- {/* Step 3 — model + run */} + {/* Step 3 - language, model + run */}

- 3Model & run + 3Language, model & run

- Embeddings run locally via sentence-transformers; the model is pinned and recorded - in the run metadata for reproducibility. + Embeddings run locally via sentence-transformers; model and language are recorded + in the run metadata. If the corpus doesn't match the selected language or the + model doesn't support it, you'll get a warning - never a silent result.

-
-
+
+ +
-
+ {auth && !auth.signed_in && auth.usage?.max_runs_per_day != null && ( +

+ {Math.min(auth.usage.runs_used_today, auth.usage.max_runs_per_day)} of{" "} + {auth.usage.max_runs_per_day} free runs used today + {auth.usage.runs_used_today >= auth.usage.max_runs_per_day + ? " - sign in (top right) to keep running." + : "."} +

+ )} + {auth?.signed_in && auth.usage?.max_saved_runs != null && ( +

+ {auth.usage.saved_runs} of {auth.usage.max_saved_runs} saved runs used. +

+ )} + {models.find((m) => m.id === modelName)?.warnings?.map((w, i) => ( +

+ ⚠ {w} +

+ ))}
{/* Jobs */} {jobs.length > 0 && (

Runs

- - - - - - - - - - - {jobs.map((j) => ( - - - - - - +
+
StartedCorpusConstructStatus -
{(j.started_at || j.created_at).replace("T", " ").slice(0, 16)}{j.corpus_filename}{j.construct_name} - {j.status === "running" ? ( -
-
-
- ) : ( - {j.status} - )} - {j.status === "failed" && ( -
- {j.error.split("\n").pop()} -
- )} -
- {j.status === "completed" && ( - - )} -
+ + + + + + + + + - ))} - -
StartedCorpusConstructModelLangStatus
+ + + {jobs.map((j) => ( + + + {(j.started_at || j.created_at).replace("T", " ").slice(0, 16)} + + {j.corpus_filename} + {j.construct_name} + {j.model_name} + {j.language} + + {j.status === "running" ? ( +
+
+
+ ) : ( + {j.status} + )} + {j.status === "failed" && ( +
+ {j.error.split("\n").pop()} +
+ )} + + + {j.status === "completed" && ( + + )} + + + ))} + + +
)} ); } +const REVERSE_SUFFIX = /\s*\((r|rev|reversed)\)\s*$/i; + function NewConstructForm({ onCreated, onError }) { const [name, setName] = useState(""); const [reference, setReference] = useState(""); const [itemsText, setItemsText] = useState(""); const [saving, setSaving] = useState(false); + const [parsing, setParsing] = useState(false); + const [parseNotes, setParseNotes] = useState([]); + const itemFileRef = useRef(null); - async function save(e) { - e.preventDefault(); - const items = itemsText + // Convention shared with the file parser and the lab's own spreadsheets: + // a trailing (R) marks a reverse-scored item. + function parseLines() { + return itemsText .split("\n") .map((s) => s.trim()) - .filter(Boolean); - if (!name.trim() || items.length === 0) { + .filter(Boolean) + .map((line) => ({ + text: line.replace(REVERSE_SUFFIX, "").trim(), + reverse: REVERSE_SUFFIX.test(line), + })); + } + + async function handleItemFile(e) { + const file = e.target.files?.[0]; + if (!file) return; + setParsing(true); + setParseNotes([]); + try { + const parsed = await api.parseConstructFile(file); + setItemsText( + parsed.items + .map((i) => (i.reverse_scored ? `${i.text} (R)` : i.text)) + .join("\n") + ); + if (!name.trim() && parsed.suggested_name) setName(parsed.suggested_name); + setParseNotes(parsed.warnings || []); + } catch (err) { + onError(err.message); + } finally { + setParsing(false); + if (itemFileRef.current) itemFileRef.current.value = ""; + } + } + + async function save(e) { + e.preventDefault(); + const parsed = parseLines(); + if (!name.trim() || parsed.length === 0) { onError("A custom construct needs a name and at least one item (one per line)."); return; } setSaving(true); try { - const created = await api.createConstruct({ name: name.trim(), reference, items }); + const created = await api.createConstruct({ + name: name.trim(), + reference, + items: parsed.map((i) => i.text), + reverse_scored: parsed.map((i) => i.reverse), + }); onCreated(created); } catch (err) { onError(err.message); @@ -324,6 +504,8 @@ function NewConstructForm({ onCreated, onError }) { } } + const nReverse = parseLines().filter((i) => i.reverse).length; + return (
@@ -345,10 +527,29 @@ function NewConstructForm({ onCreated, onError }) {