Spaces:
Sleeping
Sleeping
Commit ·
375924d
1
Parent(s): 035798d
Code refactor for extensibility
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +1 -1
- .gitignore +6 -1
- .pre-commit-config.yaml +16 -0
- CLAUDE.md +35 -26
- README.md +58 -148
- {api → backend}/__init__.py +0 -0
- {generation → backend/api}/__init__.py +0 -0
- {api → backend/api}/main.py +56 -27
- {config → backend/config}/__init__.py +1 -1
- {config → backend/config}/settings.py +19 -25
- {guardrails → backend/generation}/__init__.py +0 -0
- {generation → backend/generation}/llm_client.py +21 -55
- {pipeline → backend/guardrails}/__init__.py +0 -0
- {guardrails → backend/guardrails}/checks.py +25 -34
- main.py → backend/main.py +66 -49
- {pipeline/nodes → backend/pipeline}/__init__.py +0 -0
- {pipeline → backend/pipeline}/graph.py +12 -18
- {retrieval → backend/pipeline/nodes}/__init__.py +0 -0
- {pipeline → backend/pipeline}/nodes/feedback.py +34 -39
- {pipeline → backend/pipeline}/nodes/intent.py +45 -24
- {pipeline → backend/pipeline}/nodes/planner.py +76 -56
- {pipeline → backend/pipeline}/nodes/retrieval.py +15 -23
- {pipeline → backend/pipeline}/state.py +37 -38
- {sensing → backend/retrieval}/__init__.py +0 -0
- {retrieval → backend/retrieval}/bucket_priors.py +2 -23
- {retrieval → backend/retrieval}/clustering.py +8 -35
- {retrieval → backend/retrieval}/vector_store.py +34 -54
- backend/sensing/__init__.py +0 -0
- {sensing → backend/sensing}/air_writing.py +18 -39
- {sensing → backend/sensing}/face_mesh.py +29 -50
- {sensing → backend/sensing}/gaze.py +10 -31
- {sensing → backend/sensing}/gesture.py +18 -40
- {ui → backend/ui}/app.py +24 -14
- frontend/.gitignore +24 -0
- frontend/README.md +73 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +13 -0
- frontend/package.json +31 -0
- frontend/pnpm-lock.yaml +1863 -0
- frontend/src/App.css +262 -0
- frontend/src/App.tsx +133 -0
- frontend/src/components/ChatPanel.tsx +116 -0
- frontend/src/components/LatencyMetrics.tsx +29 -0
- frontend/src/components/PersonaSelector.tsx +42 -0
- frontend/src/components/SensingStatus.tsx +49 -0
- frontend/src/components/WebcamSensing.tsx +30 -0
- frontend/src/hooks/useSensing.ts +164 -0
- frontend/src/hooks/useWebcam.ts +102 -0
- frontend/src/index.css +111 -0
- frontend/src/lib/api.ts +39 -0
.env.example
CHANGED
|
@@ -21,7 +21,7 @@ LOCAL_BASE_URL=http://localhost:11434/v1
|
|
| 21 |
LOCAL_MODEL=gemma4:31b-cloud
|
| 22 |
|
| 23 |
# ── MLflow ────────────────────────────────────────────────────────────────────
|
| 24 |
-
MLFLOW_TRACKING_URI=
|
| 25 |
MLFLOW_EXPERIMENT=aac-chatbot
|
| 26 |
|
| 27 |
# ── Thinking mode ─────────────────────────────────────────────────────────────
|
|
|
|
| 21 |
LOCAL_MODEL=gemma4:31b-cloud
|
| 22 |
|
| 23 |
# ── MLflow ────────────────────────────────────────────────────────────────────
|
| 24 |
+
MLFLOW_TRACKING_URI=sqlite:///mlflow.db
|
| 25 |
MLFLOW_EXPERIMENT=aac-chatbot
|
| 26 |
|
| 27 |
# ── Thinking mode ─────────────────────────────────────────────────────────────
|
.gitignore
CHANGED
|
@@ -22,13 +22,18 @@ data/faiss_store/
|
|
| 22 |
# Air-writing templates (large numpy files, track separately if needed)
|
| 23 |
data/air_write_templates/
|
| 24 |
|
| 25 |
-
# MLflow
|
| 26 |
mlruns/
|
|
|
|
| 27 |
|
| 28 |
# Latency logs
|
| 29 |
timings.csv
|
| 30 |
*.csv
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# IDE
|
| 33 |
.vscode/
|
| 34 |
.idea/
|
|
|
|
| 22 |
# Air-writing templates (large numpy files, track separately if needed)
|
| 23 |
data/air_write_templates/
|
| 24 |
|
| 25 |
+
# MLflow
|
| 26 |
mlruns/
|
| 27 |
+
mlflow.db
|
| 28 |
|
| 29 |
# Latency logs
|
| 30 |
timings.csv
|
| 31 |
*.csv
|
| 32 |
|
| 33 |
+
# Frontend
|
| 34 |
+
node_modules/
|
| 35 |
+
frontend/dist/
|
| 36 |
+
|
| 37 |
# IDE
|
| 38 |
.vscode/
|
| 39 |
.idea/
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
repos:
|
| 2 |
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
| 3 |
+
rev: v0.11.12
|
| 4 |
+
hooks:
|
| 5 |
+
- id: ruff
|
| 6 |
+
args: [--fix]
|
| 7 |
+
- id: ruff-format
|
| 8 |
+
|
| 9 |
+
- repo: local
|
| 10 |
+
hooks:
|
| 11 |
+
- id: eslint
|
| 12 |
+
name: eslint
|
| 13 |
+
entry: bash -c 'cd frontend && npx eslint src/'
|
| 14 |
+
language: system
|
| 15 |
+
files: ^frontend/src/.*\.(ts|tsx)$
|
| 16 |
+
pass_filenames: false
|
CLAUDE.md
CHANGED
|
@@ -12,18 +12,25 @@ Orchestrated as a **LangGraph stateful directed graph** across five layers.
|
|
| 12 |
## Architecture
|
| 13 |
|
| 14 |
```
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
```
|
| 28 |
|
| 29 |
## Key Design Decisions
|
|
@@ -39,6 +46,8 @@ config/ Pydantic BaseSettings — all config in one place
|
|
| 39 |
- **Expression-conditioned response shaping** — affect steers tone, retrieval depth,
|
| 40 |
and candidate ranking (not just metadata annotation)
|
| 41 |
- **Bayesian bucket priors** — session-level P(bucket) updated after each accepted turn
|
|
|
|
|
|
|
| 42 |
|
| 43 |
---
|
| 44 |
|
|
@@ -57,22 +66,22 @@ config/ Pydantic BaseSettings — all config in one place
|
|
| 57 |
## How to Run
|
| 58 |
|
| 59 |
```bash
|
| 60 |
-
# One-time setup
|
| 61 |
-
|
| 62 |
|
| 63 |
-
# CLI (local Ollama tier
|
| 64 |
-
python
|
| 65 |
|
| 66 |
# Full stack
|
| 67 |
-
uvicorn api.main:app --reload
|
| 68 |
-
|
| 69 |
```
|
| 70 |
|
| 71 |
---
|
| 72 |
|
| 73 |
## Configuration
|
| 74 |
|
| 75 |
-
All config lives in [config/settings.py](config/settings.py) as Pydantic `BaseSettings`.
|
| 76 |
Copy `.env.example` → `.env` and set:
|
| 77 |
|
| 78 |
- `ACTIVE_LLM_TIER` — `local` (dev) | `primary` (GCP A100) | `fallback` (Qwen3-8B)
|
|
@@ -95,12 +104,12 @@ Copy `.env.example` → `.env` and set:
|
|
| 95 |
## Development Notes
|
| 96 |
|
| 97 |
- **Adding a persona**: add to `PERSONAS` in `data/generate_users.py`, re-run it,
|
| 98 |
-
then `python -m retrieval.vector_store` to rebuild indexes
|
| 99 |
- **Changing LLM**: set `ACTIVE_LLM_TIER` in `.env` — no code changes needed
|
| 100 |
-
- **Extending sensing**: add module under `sensing/`, wire output into
|
| 101 |
-
`PipelineState` fields in `pipeline/state.py`
|
| 102 |
-
- **Guardrail tuning**: edit signal lists in `guardrails/checks.py`
|
| 103 |
-
- **Affect → generation mapping**: `_AFFECT_CONFIG` in `pipeline/nodes/intent.py`
|
| 104 |
-
and `_PERSONA_TONE_OVERRIDES` in `pipeline/nodes/planner.py`
|
| 105 |
-
- The `.venv/` directory is local — do not read or modify files inside it
|
| 106 |
- FAISS indexes in `data/faiss_store/` are gitignored — rebuilt from source JSONs
|
|
|
|
|
|
| 12 |
## Architecture
|
| 13 |
|
| 14 |
```
|
| 15 |
+
frontend/ React + Vite + TypeScript
|
| 16 |
+
src/hooks/useSensing.ts MediaPipe JS — affect, gesture, gaze, air-writing (browser-side)
|
| 17 |
+
src/components/ChatPanel.tsx Chat UI → POST /chat with sensing labels
|
| 18 |
+
|
| 19 |
+
backend/ Python (conda env: aac-chatbot)
|
| 20 |
+
main.py CLI entry point
|
| 21 |
+
api/main.py FastAPI REST API
|
| 22 |
+
pipeline/graph.py LangGraph StateGraph (5 nodes + conditional edges)
|
| 23 |
+
pipeline/nodes/intent.py L2 — LLM + Pydantic intent routing
|
| 24 |
+
pipeline/nodes/retrieval.py L3 — FAISS + BGE retrieval (fast / full)
|
| 25 |
+
pipeline/nodes/planner.py L4 — expression-conditioned generation
|
| 26 |
+
pipeline/nodes/feedback.py L5 — MLflow logging + Bayesian priors
|
| 27 |
+
sensing/ L1 — MediaPipe face mesh, gesture, gaze, air writing (Python, CLI use)
|
| 28 |
+
retrieval/ FAISS ops, HDBSCAN clustering, Bayesian bucket priors
|
| 29 |
+
generation/ Multi-tier LLM client (vLLM primary / fallback / Ollama local)
|
| 30 |
+
guardrails/ Input + output safety checks
|
| 31 |
+
config/ Pydantic BaseSettings — all config in one place
|
| 32 |
+
|
| 33 |
+
data/ Shared data (personas, FAISS indexes)
|
| 34 |
```
|
| 35 |
|
| 36 |
## Key Design Decisions
|
|
|
|
| 46 |
- **Expression-conditioned response shaping** — affect steers tone, retrieval depth,
|
| 47 |
and candidate ranking (not just metadata annotation)
|
| 48 |
- **Bayesian bucket priors** — session-level P(bucket) updated after each accepted turn
|
| 49 |
+
- **Browser-side sensing** — MediaPipe JS runs in React frontend, only classified
|
| 50 |
+
labels (affect, gesture, gaze bucket) are sent to the backend API
|
| 51 |
|
| 52 |
---
|
| 53 |
|
|
|
|
| 66 |
## How to Run
|
| 67 |
|
| 68 |
```bash
|
| 69 |
+
# One-time setup
|
| 70 |
+
bash setup.sh
|
| 71 |
|
| 72 |
+
# CLI (local Ollama tier)
|
| 73 |
+
python -m backend.main --debug
|
| 74 |
|
| 75 |
# Full stack
|
| 76 |
+
uvicorn backend.api.main:app --reload # FastAPI on :8000
|
| 77 |
+
pnpm --dir frontend dev # React on :7550
|
| 78 |
```
|
| 79 |
|
| 80 |
---
|
| 81 |
|
| 82 |
## Configuration
|
| 83 |
|
| 84 |
+
All config lives in [backend/config/settings.py](backend/config/settings.py) as Pydantic `BaseSettings`.
|
| 85 |
Copy `.env.example` → `.env` and set:
|
| 86 |
|
| 87 |
- `ACTIVE_LLM_TIER` — `local` (dev) | `primary` (GCP A100) | `fallback` (Qwen3-8B)
|
|
|
|
| 104 |
## Development Notes
|
| 105 |
|
| 106 |
- **Adding a persona**: add to `PERSONAS` in `data/generate_users.py`, re-run it,
|
| 107 |
+
then `python -m backend.retrieval.vector_store` to rebuild indexes
|
| 108 |
- **Changing LLM**: set `ACTIVE_LLM_TIER` in `.env` — no code changes needed
|
| 109 |
+
- **Extending sensing**: add module under `backend/sensing/`, wire output into
|
| 110 |
+
`PipelineState` fields in `backend/pipeline/state.py`
|
| 111 |
+
- **Guardrail tuning**: edit signal lists in `backend/guardrails/checks.py`
|
| 112 |
+
- **Affect → generation mapping**: `_AFFECT_CONFIG` in `backend/pipeline/nodes/intent.py`
|
| 113 |
+
and `_PERSONA_TONE_OVERRIDES` in `backend/pipeline/nodes/planner.py`
|
|
|
|
| 114 |
- FAISS indexes in `data/faiss_store/` are gitignored — rebuilt from source JSONs
|
| 115 |
+
- Frontend uses pnpm, Node 22+
|
README.md
CHANGED
|
@@ -34,16 +34,20 @@ a personalized digital twin that communicates on their behalf.
|
|
| 34 |
## System Architecture
|
| 35 |
|
| 36 |
```
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
```
|
| 39 |
|
| 40 |
| Layer | Module | What it does |
|
| 41 |
|-------|--------|-------------|
|
| 42 |
-
| L1 | `
|
| 43 |
-
| L2 | `pipeline/nodes/intent.py` | LLM + Pydantic-validated intent routing |
|
| 44 |
-
| L3 | `pipeline/nodes/retrieval.py` | FAISS + BGE embeddings + cross-encoder reranking |
|
| 45 |
-
| L4 | `pipeline/nodes/planner.py` | Expression-conditioned response generation (Qwen3) |
|
| 46 |
-
| L5 | `pipeline/nodes/feedback.py` | MLflow tracking + Bayesian bucket prior update |
|
| 47 |
|
| 48 |
The pipeline runs as a **LangGraph stateful directed graph** with conditional edges:
|
| 49 |
- FRUSTRATED affect → fast retrieval path (k=2, no reranker)
|
|
@@ -53,146 +57,70 @@ The pipeline runs as a **LangGraph stateful directed graph** with conditional ed
|
|
| 53 |
|
| 54 |
## Prerequisites
|
| 55 |
|
| 56 |
-
- Python **3.10
|
|
|
|
| 57 |
- [Ollama](https://ollama.com) installed locally for the `local` LLM tier
|
| 58 |
-
- A webcam (
|
| 59 |
-
- Git
|
| 60 |
|
| 61 |
---
|
| 62 |
|
| 63 |
## Setup
|
| 64 |
|
| 65 |
-
### 1. Clone the repository
|
| 66 |
-
|
| 67 |
```bash
|
| 68 |
git clone https://github.com/akashkolte/multimodal_aac_chatbot.git
|
| 69 |
cd multimodal_aac_chatbot
|
|
|
|
| 70 |
```
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
```bash
|
| 81 |
-
python3 -m venv .venv
|
| 82 |
-
source .venv/bin/activate # macOS / Linux
|
| 83 |
-
# .venv\Scripts\activate # Windows
|
| 84 |
-
```
|
| 85 |
-
|
| 86 |
-
### 4. Install dependencies
|
| 87 |
-
|
| 88 |
-
```bash
|
| 89 |
-
pip install -r requirements.txt
|
| 90 |
-
```
|
| 91 |
-
|
| 92 |
-
> This installs LangGraph, FAISS, sentence-transformers (BGE), FastAPI, Streamlit, MLflow,
|
| 93 |
-
> MediaPipe, and all other dependencies.
|
| 94 |
-
|
| 95 |
-
### 5. Configure environment variables
|
| 96 |
-
|
| 97 |
-
```bash
|
| 98 |
-
cp .env.example .env
|
| 99 |
-
```
|
| 100 |
-
|
| 101 |
-
Open `.env` and set at minimum:
|
| 102 |
-
|
| 103 |
-
```env
|
| 104 |
-
ACTIVE_LLM_TIER=local # use Ollama on your machine for dev
|
| 105 |
-
```
|
| 106 |
-
|
| 107 |
-
See [Configuration](#configuration) for all options.
|
| 108 |
-
|
| 109 |
-
### 6. Pull the local LLM model (Ollama)
|
| 110 |
-
|
| 111 |
-
```bash
|
| 112 |
-
ollama pull qwen3:8b
|
| 113 |
-
```
|
| 114 |
-
|
| 115 |
-
> Make sure Ollama is running (`ollama serve`) before starting the chatbot.
|
| 116 |
-
|
| 117 |
-
### 7. Build FAISS indexes
|
| 118 |
-
|
| 119 |
-
The persona memory indexes must be built once with the BGE embedder before first run:
|
| 120 |
-
|
| 121 |
-
```bash
|
| 122 |
-
python -m retrieval.vector_store
|
| 123 |
-
```
|
| 124 |
-
|
| 125 |
-
Expected output:
|
| 126 |
-
```
|
| 127 |
-
Building index for arjun_mehta … Saved 25 chunks
|
| 128 |
-
Building index for gerald_okafor … Saved 25 chunks
|
| 129 |
-
Building index for mia_chen … Saved 25 chunks
|
| 130 |
-
All indexes built.
|
| 131 |
-
```
|
| 132 |
-
|
| 133 |
-
> You must re-run this step whenever you add or edit persona memory files.
|
| 134 |
|
| 135 |
---
|
| 136 |
|
| 137 |
## Configuration
|
| 138 |
|
| 139 |
-
All settings live in [config/settings.py](config/settings.py) and can be overridden via `.env`.
|
| 140 |
|
| 141 |
| Variable | Default | Description |
|
| 142 |
|----------|---------|-------------|
|
| 143 |
| `ACTIVE_LLM_TIER` | `local` | `local` (Ollama) \| `primary` (vLLM GCP) \| `fallback` (Qwen3-8B) |
|
| 144 |
| `LOCAL_MODEL` | `qwen3:8b` | Ollama model name for local dev |
|
| 145 |
| `LOCAL_BASE_URL` | `http://localhost:11434/v1` | Ollama OpenAI-compatible endpoint |
|
| 146 |
-
| `PRIMARY_BASE_URL` | *(GCP IP)* | vLLM server URL on GCP
|
| 147 |
| `PRIMARY_MODEL` | `Qwen/Qwen3-30B-A3B` | Primary MoE model served via vLLM |
|
| 148 |
| `FALLBACK_LATENCY_THRESHOLD` | `3.5` | Seconds before falling back to smaller model |
|
| 149 |
| `MLFLOW_TRACKING_URI` | `mlruns` | Local MLflow storage path |
|
| 150 |
-
| `MLFLOW_EXPERIMENT` | `aac-chatbot` | MLflow experiment name |
|
| 151 |
|
| 152 |
---
|
| 153 |
|
| 154 |
## Running the Project
|
| 155 |
|
| 156 |
-
###
|
| 157 |
-
|
| 158 |
-
```bash
|
| 159 |
-
python main.py
|
| 160 |
-
```
|
| 161 |
-
|
| 162 |
-
With debug latency output:
|
| 163 |
-
```bash
|
| 164 |
-
python main.py --debug
|
| 165 |
-
```
|
| 166 |
|
| 167 |
-
Select a specific persona and LLM tier:
|
| 168 |
```bash
|
| 169 |
-
|
| 170 |
```
|
| 171 |
|
| 172 |
-
|
|
|
|
| 173 |
|
| 174 |
-
|
| 175 |
-
```bash
|
| 176 |
-
uvicorn api.main:app --reload --port 8000
|
| 177 |
-
```
|
| 178 |
|
| 179 |
-
Start the Streamlit frontend in another terminal:
|
| 180 |
```bash
|
| 181 |
-
|
|
|
|
| 182 |
```
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
The UI includes:
|
| 187 |
-
- Persona selector
|
| 188 |
-
- Affect override controls (simulate webcam for testing)
|
| 189 |
-
- Live chat interface
|
| 190 |
-
- Per-turn latency breakdown panel
|
| 191 |
-
|
| 192 |
-
### Option C — API only (for integration / testing)
|
| 193 |
|
| 194 |
```bash
|
| 195 |
-
|
|
|
|
| 196 |
```
|
| 197 |
|
| 198 |
Example request:
|
|
@@ -208,52 +136,34 @@ curl -X POST http://localhost:8000/chat \
|
|
| 208 |
|
| 209 |
```
|
| 210 |
multimodal_aac_chatbot/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
│
|
| 212 |
-
├──
|
| 213 |
-
│
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
│
|
| 215 |
├── data/
|
| 216 |
-
│ ├──
|
| 217 |
-
│ ├──
|
| 218 |
-
│
|
| 219 |
-
│ └── faiss_store/ # Built FAISS indexes (gitignored, rebuild locally)
|
| 220 |
-
│
|
| 221 |
-
├── sensing/ # L1 — multimodal input
|
| 222 |
-
│ ├── face_mesh.py # MediaPipe affect detection (MAR/EAR/BRI/LCP)
|
| 223 |
-
│ ├── gesture.py # Hand gesture classifier
|
| 224 |
-
│ ├── gaze.py # Gaze-based bucket activation (bonus)
|
| 225 |
-
│ └── air_writing.py # DTW air-writing stroke classifier (bonus)
|
| 226 |
-
│
|
| 227 |
-
├── pipeline/ # LangGraph orchestration
|
| 228 |
-
│ ├── state.py # Typed PipelineState (TypedDict)
|
| 229 |
-
│ ├── graph.py # Graph definition + conditional edges
|
| 230 |
-
│ └── nodes/
|
| 231 |
-
│ ├── intent.py # L2 — LLM + Pydantic routing
|
| 232 |
-
│ ├── retrieval.py # L3 — fast + full retrieval paths
|
| 233 |
-
│ ├── planner.py # L4 — expression-conditioned generation
|
| 234 |
-
│ └── feedback.py # L5 — MLflow + Bayesian prior update
|
| 235 |
-
│
|
| 236 |
-
├── retrieval/
|
| 237 |
-
│ ├── vector_store.py # FAISS ops with BGE-small-en-v1.5
|
| 238 |
-
│ ├── clustering.py # HDBSCAN semantic bucketing
|
| 239 |
-
│ └── bucket_priors.py # Bayesian session priors
|
| 240 |
-
│
|
| 241 |
-
├── generation/
|
| 242 |
-
│ └── llm_client.py # 3-tier LLM client (vLLM / Ollama)
|
| 243 |
-
│
|
| 244 |
-
├── guardrails/
|
| 245 |
-
│ └── checks.py # Input + output safety checks
|
| 246 |
-
│
|
| 247 |
-
├── api/
|
| 248 |
-
│ └── main.py # FastAPI backend
|
| 249 |
-
│
|
| 250 |
-
├── ui/
|
| 251 |
-
│ └── app.py # Streamlit frontend
|
| 252 |
│
|
| 253 |
-
├──
|
| 254 |
-
├──
|
| 255 |
-
├── .
|
| 256 |
-
└──
|
| 257 |
```
|
| 258 |
|
| 259 |
---
|
|
@@ -268,7 +178,7 @@ multimodal_aac_chatbot/
|
|
| 268 |
|
| 269 |
Each persona has 25 memory chunks across 5 buckets: `family`, `medical`, `hobbies`, `daily_routine`, `social`.
|
| 270 |
|
| 271 |
-
To add a new persona, edit `data/generate_users.py` and re-run `python -m retrieval.vector_store`.
|
| 272 |
|
| 273 |
---
|
| 274 |
|
|
|
|
| 34 |
## System Architecture
|
| 35 |
|
| 36 |
```
|
| 37 |
+
React Frontend (browser) Backend (Python)
|
| 38 |
+
MediaPipe JS sensing ──┐
|
| 39 |
+
Chat UI ───────────────┼── POST /chat ──► FastAPI ──► LangGraph Pipeline
|
| 40 |
+
Webcam feed ───────────┘ │
|
| 41 |
+
L2 Intent ──► L3 Retrieval ──► L4 Generation ──► L5 Feedback
|
| 42 |
```
|
| 43 |
|
| 44 |
| Layer | Module | What it does |
|
| 45 |
|-------|--------|-------------|
|
| 46 |
+
| L1 | `frontend/src/hooks/useSensing.ts` | MediaPipe JS — affect, gesture, gaze, air writing (browser-side) |
|
| 47 |
+
| L2 | `backend/pipeline/nodes/intent.py` | LLM + Pydantic-validated intent routing |
|
| 48 |
+
| L3 | `backend/pipeline/nodes/retrieval.py` | FAISS + BGE embeddings + cross-encoder reranking |
|
| 49 |
+
| L4 | `backend/pipeline/nodes/planner.py` | Expression-conditioned response generation (Qwen3) |
|
| 50 |
+
| L5 | `backend/pipeline/nodes/feedback.py` | MLflow tracking + Bayesian bucket prior update |
|
| 51 |
|
| 52 |
The pipeline runs as a **LangGraph stateful directed graph** with conditional edges:
|
| 53 |
- FRUSTRATED affect → fast retrieval path (k=2, no reranker)
|
|
|
|
| 57 |
|
| 58 |
## Prerequisites
|
| 59 |
|
| 60 |
+
- Python **3.10+** (via conda)
|
| 61 |
+
- Node.js **22+** and **pnpm**
|
| 62 |
- [Ollama](https://ollama.com) installed locally for the `local` LLM tier
|
| 63 |
+
- A webcam (for live sensing; optional for CLI mode)
|
|
|
|
| 64 |
|
| 65 |
---
|
| 66 |
|
| 67 |
## Setup
|
| 68 |
|
|
|
|
|
|
|
| 69 |
```bash
|
| 70 |
git clone https://github.com/akashkolte/multimodal_aac_chatbot.git
|
| 71 |
cd multimodal_aac_chatbot
|
| 72 |
+
bash setup.sh
|
| 73 |
```
|
| 74 |
|
| 75 |
+
The setup script handles:
|
| 76 |
+
- Conda environment creation (`aac-chatbot`, Python 3.12)
|
| 77 |
+
- Python dependency installation
|
| 78 |
+
- `.env` file creation from template
|
| 79 |
+
- FAISS index building (downloads BGE models on first run)
|
| 80 |
+
- Ollama model pull
|
| 81 |
+
- Frontend dependency installation (pnpm)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
---
|
| 84 |
|
| 85 |
## Configuration
|
| 86 |
|
| 87 |
+
All settings live in [backend/config/settings.py](backend/config/settings.py) and can be overridden via `.env`.
|
| 88 |
|
| 89 |
| Variable | Default | Description |
|
| 90 |
|----------|---------|-------------|
|
| 91 |
| `ACTIVE_LLM_TIER` | `local` | `local` (Ollama) \| `primary` (vLLM GCP) \| `fallback` (Qwen3-8B) |
|
| 92 |
| `LOCAL_MODEL` | `qwen3:8b` | Ollama model name for local dev |
|
| 93 |
| `LOCAL_BASE_URL` | `http://localhost:11434/v1` | Ollama OpenAI-compatible endpoint |
|
| 94 |
+
| `PRIMARY_BASE_URL` | *(GCP IP)* | vLLM server URL on GCP |
|
| 95 |
| `PRIMARY_MODEL` | `Qwen/Qwen3-30B-A3B` | Primary MoE model served via vLLM |
|
| 96 |
| `FALLBACK_LATENCY_THRESHOLD` | `3.5` | Seconds before falling back to smaller model |
|
| 97 |
| `MLFLOW_TRACKING_URI` | `mlruns` | Local MLflow storage path |
|
|
|
|
| 98 |
|
| 99 |
---
|
| 100 |
|
| 101 |
## Running the Project
|
| 102 |
|
| 103 |
+
### Full stack (recommended)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
|
|
|
| 105 |
```bash
|
| 106 |
+
bash run.sh
|
| 107 |
```
|
| 108 |
|
| 109 |
+
This starts Ollama (if needed), FastAPI on `:8000`, and React on `:7550`.
|
| 110 |
+
Open [http://localhost:7550](http://localhost:7550) in your browser.
|
| 111 |
|
| 112 |
+
### CLI only
|
|
|
|
|
|
|
|
|
|
| 113 |
|
|
|
|
| 114 |
```bash
|
| 115 |
+
conda activate aac-chatbot
|
| 116 |
+
python -m backend.main --debug
|
| 117 |
```
|
| 118 |
|
| 119 |
+
### API only
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
```bash
|
| 122 |
+
conda activate aac-chatbot
|
| 123 |
+
uvicorn backend.api.main:app --reload
|
| 124 |
```
|
| 125 |
|
| 126 |
Example request:
|
|
|
|
| 136 |
|
| 137 |
```
|
| 138 |
multimodal_aac_chatbot/
|
| 139 |
+
├── frontend/ React + Vite + TypeScript
|
| 140 |
+
│ └── src/
|
| 141 |
+
│ ├── components/ Chat UI, webcam, sensing status
|
| 142 |
+
│ ├── hooks/ useWebcam, useSensing (MediaPipe JS)
|
| 143 |
+
│ └── lib/ API client, sensing classification, DTW
|
| 144 |
│
|
| 145 |
+
├── backend/ Python (conda env: aac-chatbot)
|
| 146 |
+
│ ├── main.py CLI entry point
|
| 147 |
+
│ ├── api/main.py FastAPI REST API
|
| 148 |
+
│ ├── config/settings.py Pydantic BaseSettings
|
| 149 |
+
│ ├── pipeline/
|
| 150 |
+
│ │ ├── graph.py LangGraph StateGraph
|
| 151 |
+
│ │ ├── state.py PipelineState TypedDict
|
| 152 |
+
│ │ └── nodes/ intent, retrieval, planner, feedback
|
| 153 |
+
│ ├── sensing/ MediaPipe modules (Python, CLI use)
|
| 154 |
+
│ ├── retrieval/ FAISS, BGE, HDBSCAN, bucket priors
|
| 155 |
+
│ ├── generation/llm_client.py 3-tier LLM client (vLLM / Ollama)
|
| 156 |
+
│ └── guardrails/checks.py Input + output safety checks
|
| 157 |
│
|
| 158 |
├── data/
|
| 159 |
+
│ ├── users.json Persona index
|
| 160 |
+
│ ├── memories/ Per-persona memory JSONs
|
| 161 |
+
│ └── faiss_store/ FAISS indexes (gitignored, rebuilt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
│
|
| 163 |
+
├── setup.sh One-time setup script
|
| 164 |
+
├── run.sh Start backend + frontend
|
| 165 |
+
├── requirements.txt Python dependencies
|
| 166 |
+
└── .env.example Environment variable template
|
| 167 |
```
|
| 168 |
|
| 169 |
---
|
|
|
|
| 178 |
|
| 179 |
Each persona has 25 memory chunks across 5 buckets: `family`, `medical`, `hobbies`, `daily_routine`, `social`.
|
| 180 |
|
| 181 |
+
To add a new persona, edit `data/generate_users.py` and re-run `python -m backend.retrieval.vector_store`.
|
| 182 |
|
| 183 |
---
|
| 184 |
|
{api → backend}/__init__.py
RENAMED
|
File without changes
|
{generation → backend/api}/__init__.py
RENAMED
|
File without changes
|
{api → backend/api}/main.py
RENAMED
|
@@ -1,29 +1,19 @@
|
|
| 1 |
-
|
| 2 |
-
FastAPI backend — exposes the LangGraph pipeline as a REST API.
|
| 3 |
-
|
| 4 |
-
Endpoints:
|
| 5 |
-
POST /chat — single-turn inference (non-streaming)
|
| 6 |
-
POST /chat/stream — streaming token delivery via SSE
|
| 7 |
-
GET /users — list available personas
|
| 8 |
-
POST /session/reset — reset session state for a user
|
| 9 |
-
GET /health — liveness check
|
| 10 |
-
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import json
|
| 14 |
-
import time
|
| 15 |
-
from typing import AsyncGenerator
|
| 16 |
|
| 17 |
from fastapi import FastAPI, HTTPException
|
| 18 |
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
-
from fastapi.responses import StreamingResponse
|
| 20 |
from pydantic import BaseModel
|
| 21 |
|
| 22 |
-
from config.settings import settings
|
| 23 |
-
from
|
| 24 |
-
from
|
| 25 |
-
from pipeline.
|
| 26 |
-
from
|
|
|
|
|
|
|
| 27 |
|
| 28 |
app = FastAPI(
|
| 29 |
title="Multimodal AAC Chatbot API",
|
|
@@ -38,18 +28,39 @@ app.add_middleware(
|
|
| 38 |
allow_headers=["*"],
|
| 39 |
)
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
# ── In-memory session store (replace with Redis for multi-worker deployments) ──
|
| 42 |
_sessions: dict[str, dict] = {}
|
| 43 |
|
| 44 |
|
| 45 |
# ── Request / response schemas ─────────────────────────────────────────────────
|
| 46 |
|
|
|
|
| 47 |
class ChatRequest(BaseModel):
|
| 48 |
user_id: str
|
| 49 |
query: str
|
| 50 |
-
affect_override: str | None = None
|
| 51 |
gesture_tag: str | None = None
|
| 52 |
gaze_bucket: str | None = None
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
class ChatResponse(BaseModel):
|
|
@@ -65,10 +76,16 @@ class ChatResponse(BaseModel):
|
|
| 65 |
|
| 66 |
# ── Helpers ────────────────────────────────────────────────────────────────────
|
| 67 |
|
|
|
|
| 68 |
def _get_or_init_session(user_id: str) -> dict:
|
| 69 |
if user_id not in _sessions:
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
if user_id not in users:
|
| 73 |
raise HTTPException(status_code=404, detail=f"User '{user_id}' not found")
|
| 74 |
_sessions[user_id] = {
|
|
@@ -95,7 +112,7 @@ def _build_initial_state(req: ChatRequest, session: dict) -> PipelineState:
|
|
| 95 |
affect=affect_state,
|
| 96 |
gesture_tag=req.gesture_tag,
|
| 97 |
gaze_bucket=req.gaze_bucket,
|
| 98 |
-
air_written_text=
|
| 99 |
raw_query=req.query,
|
| 100 |
intent_route=None,
|
| 101 |
generation_config=None,
|
|
@@ -106,7 +123,13 @@ def _build_initial_state(req: ChatRequest, session: dict) -> PipelineState:
|
|
| 106 |
candidates=[],
|
| 107 |
selected_response=None,
|
| 108 |
llm_tier_used="",
|
| 109 |
-
latency_log={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
mlflow_run_id=None,
|
| 111 |
guardrail_passed=True,
|
| 112 |
)
|
|
@@ -114,15 +137,21 @@ def _build_initial_state(req: ChatRequest, session: dict) -> PipelineState:
|
|
| 114 |
|
| 115 |
# ── Routes ─────────────────────────────────────────────────────────────────────
|
| 116 |
|
|
|
|
| 117 |
@app.get("/health")
|
| 118 |
def health():
|
| 119 |
-
return {"status": "ok"}
|
| 120 |
|
| 121 |
|
| 122 |
@app.get("/users")
|
| 123 |
def list_users():
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
|
| 128 |
@app.post("/session/reset")
|
|
@@ -153,7 +182,7 @@ def chat(req: ChatRequest):
|
|
| 153 |
|
| 154 |
# Persist updated session state
|
| 155 |
session["session_history"] = result["session_history"]
|
| 156 |
-
session["bucket_priors"]
|
| 157 |
|
| 158 |
return ChatResponse(
|
| 159 |
user_id=req.user_id,
|
|
|
|
| 1 |
+
# FastAPI backend — REST API for the AAC pipeline.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import json
|
|
|
|
|
|
|
| 5 |
|
| 6 |
from fastapi import FastAPI, HTTPException
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 8 |
from pydantic import BaseModel
|
| 9 |
|
| 10 |
+
from backend.config.settings import settings
|
| 11 |
+
from backend.generation.llm_client import get_client
|
| 12 |
+
from backend.guardrails.checks import check_input
|
| 13 |
+
from backend.pipeline.graph import aac_graph
|
| 14 |
+
from backend.pipeline.state import PipelineState
|
| 15 |
+
from backend.retrieval.bucket_priors import uniform_priors
|
| 16 |
+
from backend.retrieval.vector_store import _get_embedder, _get_reranker
|
| 17 |
|
| 18 |
app = FastAPI(
|
| 19 |
title="Multimodal AAC Chatbot API",
|
|
|
|
| 28 |
allow_headers=["*"],
|
| 29 |
)
|
| 30 |
|
| 31 |
+
_models_ready = False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@app.on_event("startup")
|
| 35 |
+
def _warmup():
|
| 36 |
+
global _models_ready
|
| 37 |
+
import logging
|
| 38 |
+
import os
|
| 39 |
+
|
| 40 |
+
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
| 41 |
+
logging.getLogger("sentence_transformers").setLevel(logging.WARNING)
|
| 42 |
+
print("Loading models...", end=" ", flush=True)
|
| 43 |
+
_get_embedder()
|
| 44 |
+
_get_reranker()
|
| 45 |
+
get_client()
|
| 46 |
+
_models_ready = True
|
| 47 |
+
print("ready.")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
# ── In-memory session store (replace with Redis for multi-worker deployments) ──
|
| 51 |
_sessions: dict[str, dict] = {}
|
| 52 |
|
| 53 |
|
| 54 |
# ── Request / response schemas ─────────────────────────────────────────────────
|
| 55 |
|
| 56 |
+
|
| 57 |
class ChatRequest(BaseModel):
|
| 58 |
user_id: str
|
| 59 |
query: str
|
| 60 |
+
affect_override: str | None = None # "HAPPY"|"FRUSTRATED"|"NEUTRAL"|"SURPRISED"
|
| 61 |
gesture_tag: str | None = None
|
| 62 |
gaze_bucket: str | None = None
|
| 63 |
+
air_written_text: str | None = None
|
| 64 |
|
| 65 |
|
| 66 |
class ChatResponse(BaseModel):
|
|
|
|
| 76 |
|
| 77 |
# ── Helpers ────────────────────────────────────────────────────────────────────
|
| 78 |
|
| 79 |
+
|
| 80 |
def _get_or_init_session(user_id: str) -> dict:
|
| 81 |
if user_id not in _sessions:
|
| 82 |
+
try:
|
| 83 |
+
with open(settings.users_json) as f:
|
| 84 |
+
users = {u["id"]: u for u in json.load(f)["users"]}
|
| 85 |
+
except FileNotFoundError as e:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=503, detail="users.json not found — run setup.sh"
|
| 88 |
+
) from e
|
| 89 |
if user_id not in users:
|
| 90 |
raise HTTPException(status_code=404, detail=f"User '{user_id}' not found")
|
| 91 |
_sessions[user_id] = {
|
|
|
|
| 112 |
affect=affect_state,
|
| 113 |
gesture_tag=req.gesture_tag,
|
| 114 |
gaze_bucket=req.gaze_bucket,
|
| 115 |
+
air_written_text=req.air_written_text,
|
| 116 |
raw_query=req.query,
|
| 117 |
intent_route=None,
|
| 118 |
generation_config=None,
|
|
|
|
| 123 |
candidates=[],
|
| 124 |
selected_response=None,
|
| 125 |
llm_tier_used="",
|
| 126 |
+
latency_log={
|
| 127 |
+
"t_sensing": 0.0,
|
| 128 |
+
"t_intent": 0.0,
|
| 129 |
+
"t_retrieval": 0.0,
|
| 130 |
+
"t_generation": 0.0,
|
| 131 |
+
"t_total": 0.0,
|
| 132 |
+
},
|
| 133 |
mlflow_run_id=None,
|
| 134 |
guardrail_passed=True,
|
| 135 |
)
|
|
|
|
| 137 |
|
| 138 |
# ── Routes ─────────────────────────────────────────────────────────────────────
|
| 139 |
|
| 140 |
+
|
| 141 |
@app.get("/health")
|
| 142 |
def health():
|
| 143 |
+
return {"status": "ok", "models_ready": _models_ready}
|
| 144 |
|
| 145 |
|
| 146 |
@app.get("/users")
|
| 147 |
def list_users():
|
| 148 |
+
try:
|
| 149 |
+
with open(settings.users_json) as f:
|
| 150 |
+
return json.load(f)
|
| 151 |
+
except FileNotFoundError as e:
|
| 152 |
+
raise HTTPException(
|
| 153 |
+
status_code=503, detail="users.json not found — run setup.sh"
|
| 154 |
+
) from e
|
| 155 |
|
| 156 |
|
| 157 |
@app.post("/session/reset")
|
|
|
|
| 182 |
|
| 183 |
# Persist updated session state
|
| 184 |
session["session_history"] = result["session_history"]
|
| 185 |
+
session["bucket_priors"] = result["bucket_priors"]
|
| 186 |
|
| 187 |
return ChatResponse(
|
| 188 |
user_id=req.user_id,
|
{config → backend/config}/__init__.py
RENAMED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
-
from
|
| 2 |
|
| 3 |
__all__ = ["settings"]
|
|
|
|
| 1 |
+
from .settings import settings
|
| 2 |
|
| 3 |
__all__ = ["settings"]
|
{config → backend/config}/settings.py
RENAMED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
from pathlib import Path
|
|
|
|
| 2 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 3 |
|
| 4 |
|
| 5 |
class Settings(BaseSettings):
|
| 6 |
-
model_config = SettingsConfigDict(
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# ── Paths ──────────────────────────────────────────────────────────────────
|
| 9 |
data_dir: Path = Path("data")
|
|
@@ -16,17 +19,18 @@ class Settings(BaseSettings):
|
|
| 16 |
rerank_model: str = "BAAI/bge-reranker-v2-m3"
|
| 17 |
retrieval_top_k: int = 5
|
| 18 |
retrieval_rerank_k: int = 3
|
| 19 |
-
retrieval_fast_k: int = 2
|
| 20 |
|
| 21 |
# ── LLM tiers ─────────────────────────────────────────────────────────────
|
| 22 |
# Tier 1 — primary (Qwen3-30B-A3B via vLLM on GCP)
|
| 23 |
primary_model: str = "Qwen/Qwen3-30B-A3B"
|
| 24 |
primary_base_url: str = "http://localhost:8000/v1"
|
| 25 |
-
primary_api_key: str = "token-abc"
|
| 26 |
|
| 27 |
# Tier 2 — fallback dense model (Qwen3-8B via vLLM, same server)
|
| 28 |
fallback_model: str = "Qwen/Qwen3-8B"
|
| 29 |
fallback_base_url: str = "http://localhost:8000/v1"
|
|
|
|
| 30 |
|
| 31 |
# Tier 3 — local dev (Ollama on MacBook M2)
|
| 32 |
local_model: str = "qwen3:8b"
|
|
@@ -36,44 +40,34 @@ class Settings(BaseSettings):
|
|
| 36 |
# Active tier: "primary" | "fallback" | "local"
|
| 37 |
active_llm_tier: str = "local"
|
| 38 |
|
| 39 |
-
#
|
| 40 |
-
# "strip" = let model think, but strip <think> tags from output
|
| 41 |
-
# "full" = return raw response including <think> blocks
|
| 42 |
-
# "suppress" = actively suppress thinking via /no_think (Ollama) or
|
| 43 |
-
# chat_template_kwargs (vLLM). Use for models like Qwen3
|
| 44 |
-
# that think by default and need explicit suppression.
|
| 45 |
thinking_mode: str = "off"
|
| 46 |
-
|
| 47 |
-
# Extra token budget added on top of max_tokens when thinking is enabled
|
| 48 |
-
# (thinking_mode = "strip" or "full"). Set to 0 if using a non-thinking model.
|
| 49 |
thinking_token_budget: int = 4096
|
| 50 |
-
|
| 51 |
-
# Wall-clock threshold (seconds) that triggers fallback within a turn
|
| 52 |
-
fallback_latency_threshold: float = 3.5
|
| 53 |
|
| 54 |
# ── Generation ────────────────────────────────────────────────────────────
|
| 55 |
max_tokens_happy: int = 150
|
| 56 |
max_tokens_neutral: int = 100
|
| 57 |
max_tokens_frustrated: int = 60
|
| 58 |
max_tokens_surprised: int = 80
|
| 59 |
-
num_candidates: int = 2
|
| 60 |
|
| 61 |
# ── Sensing ───────────────────────────────────────────────────────────────
|
| 62 |
-
affect_ema_alpha: float = 0.3
|
| 63 |
gaze_dwell_threshold_s: float = 1.5
|
| 64 |
air_write_velocity_start: int = 15 # px/frame — stroke begin threshold
|
| 65 |
-
air_write_velocity_end: int = 5
|
| 66 |
-
air_write_end_gap_ms: int = 200
|
| 67 |
-
conflict_overlap_ms: int = 500
|
| 68 |
|
| 69 |
# ── MLflow ────────────────────────────────────────────────────────────────
|
| 70 |
-
mlflow_tracking_uri: str = "
|
| 71 |
mlflow_experiment: str = "aac-chatbot"
|
| 72 |
|
| 73 |
-
# ── Candidate ranking weights
|
| 74 |
-
rank_alpha: float = 0.4
|
| 75 |
-
rank_beta: float = 0.3
|
| 76 |
-
rank_gamma: float = 0.3
|
| 77 |
|
| 78 |
|
| 79 |
settings = Settings()
|
|
|
|
| 1 |
from pathlib import Path
|
| 2 |
+
|
| 3 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
|
| 5 |
|
| 6 |
class Settings(BaseSettings):
|
| 7 |
+
model_config = SettingsConfigDict(
|
| 8 |
+
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
| 9 |
+
)
|
| 10 |
|
| 11 |
# ── Paths ──────────────────────────────────────────────────────────────────
|
| 12 |
data_dir: Path = Path("data")
|
|
|
|
| 19 |
rerank_model: str = "BAAI/bge-reranker-v2-m3"
|
| 20 |
retrieval_top_k: int = 5
|
| 21 |
retrieval_rerank_k: int = 3
|
| 22 |
+
retrieval_fast_k: int = 2 # used when affect == FRUSTRATED
|
| 23 |
|
| 24 |
# ── LLM tiers ─────────────────────────────────────────────────────────────
|
| 25 |
# Tier 1 — primary (Qwen3-30B-A3B via vLLM on GCP)
|
| 26 |
primary_model: str = "Qwen/Qwen3-30B-A3B"
|
| 27 |
primary_base_url: str = "http://localhost:8000/v1"
|
| 28 |
+
primary_api_key: str = "token-abc" # vLLM default
|
| 29 |
|
| 30 |
# Tier 2 — fallback dense model (Qwen3-8B via vLLM, same server)
|
| 31 |
fallback_model: str = "Qwen/Qwen3-8B"
|
| 32 |
fallback_base_url: str = "http://localhost:8000/v1"
|
| 33 |
+
fallback_api_key: str = "token-abc"
|
| 34 |
|
| 35 |
# Tier 3 — local dev (Ollama on MacBook M2)
|
| 36 |
local_model: str = "qwen3:8b"
|
|
|
|
| 40 |
# Active tier: "primary" | "fallback" | "local"
|
| 41 |
active_llm_tier: str = "local"
|
| 42 |
|
| 43 |
+
# off | strip | full | suppress
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
thinking_mode: str = "off"
|
|
|
|
|
|
|
|
|
|
| 45 |
thinking_token_budget: int = 4096
|
| 46 |
+
fallback_latency_threshold: float = 3.5 # seconds before tier fallback
|
|
|
|
|
|
|
| 47 |
|
| 48 |
# ── Generation ────────────────────────────────────────────────────────────
|
| 49 |
max_tokens_happy: int = 150
|
| 50 |
max_tokens_neutral: int = 100
|
| 51 |
max_tokens_frustrated: int = 60
|
| 52 |
max_tokens_surprised: int = 80
|
| 53 |
+
num_candidates: int = 2 # responses generated per turn for ranking
|
| 54 |
|
| 55 |
# ── Sensing ───────────────────────────────────────────────────────────────
|
| 56 |
+
affect_ema_alpha: float = 0.3 # exponential moving average smoothing
|
| 57 |
gaze_dwell_threshold_s: float = 1.5
|
| 58 |
air_write_velocity_start: int = 15 # px/frame — stroke begin threshold
|
| 59 |
+
air_write_velocity_end: int = 5 # px/frame — stroke end threshold
|
| 60 |
+
air_write_end_gap_ms: int = 200 # ms of stillness to end a stroke
|
| 61 |
+
conflict_overlap_ms: int = 500 # audio + gesture co-occurrence window
|
| 62 |
|
| 63 |
# ── MLflow ────────────────────────────────────────────────────────────────
|
| 64 |
+
mlflow_tracking_uri: str = "sqlite:///mlflow.db"
|
| 65 |
mlflow_experiment: str = "aac-chatbot"
|
| 66 |
|
| 67 |
+
# ── Candidate ranking weights ───────────────────────────────────────────────
|
| 68 |
+
rank_alpha: float = 0.4 # faithfulness weight
|
| 69 |
+
rank_beta: float = 0.3 # style similarity weight
|
| 70 |
+
rank_gamma: float = 0.3 # affect-match weight
|
| 71 |
|
| 72 |
|
| 73 |
settings = Settings()
|
{guardrails → backend/generation}/__init__.py
RENAMED
|
File without changes
|
{generation → backend/generation}/llm_client.py
RENAMED
|
@@ -1,21 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
Multi-tier LLM client (proposal §5.6).
|
| 3 |
-
|
| 4 |
-
All three tiers expose the same OpenAI-compatible API, so only the
|
| 5 |
-
base_url + model name change — no code-path differences downstream.
|
| 6 |
-
|
| 7 |
-
Tier 1 — primary: Qwen3-30B-A3B via vLLM on GCP (A100 / T4)
|
| 8 |
-
Tier 2 — fallback: Qwen3-8B via vLLM on same server (latency > 3.5 s)
|
| 9 |
-
Tier 3 — local: Qwen3-8B via Ollama on MacBook M2 (dev / offline)
|
| 10 |
-
|
| 11 |
-
Active tier is controlled by settings.active_llm_tier or the `tier`
|
| 12 |
-
argument passed explicitly by the planner node.
|
| 13 |
-
|
| 14 |
-
Thinking mode is controlled by settings.thinking_mode:
|
| 15 |
-
"off" — prepend /no_think (Ollama) or chat_template_kwargs (vLLM)
|
| 16 |
-
"strip" — let the model think, but strip <think>…</think> from output
|
| 17 |
-
"full" — return everything including <think> blocks
|
| 18 |
-
"""
|
| 19 |
from __future__ import annotations
|
| 20 |
|
| 21 |
import re
|
|
@@ -24,47 +7,41 @@ from typing import Any
|
|
| 24 |
|
| 25 |
from openai import OpenAI
|
| 26 |
|
| 27 |
-
from config.settings import settings
|
| 28 |
|
| 29 |
|
| 30 |
@lru_cache(maxsize=3)
|
| 31 |
def _build_client(base_url: str, api_key: str) -> OpenAI:
|
| 32 |
-
"""One cached OpenAI client per (base_url, api_key) pair."""
|
| 33 |
return OpenAI(base_url=base_url, api_key=api_key)
|
| 34 |
|
| 35 |
|
| 36 |
def get_client(tier: str | None = None) -> OpenAI:
|
| 37 |
-
"""
|
| 38 |
-
Return the OpenAI-compatible client for the requested tier.
|
| 39 |
-
|
| 40 |
-
Args:
|
| 41 |
-
tier: "primary" | "fallback" | "local" | None (uses settings.active_llm_tier)
|
| 42 |
-
"""
|
| 43 |
resolved = tier or settings.active_llm_tier
|
| 44 |
|
| 45 |
if resolved == "primary":
|
| 46 |
return _build_client(settings.primary_base_url, settings.primary_api_key)
|
| 47 |
if resolved == "fallback":
|
| 48 |
-
return _build_client(settings.fallback_base_url, settings.
|
| 49 |
# local / default
|
| 50 |
return _build_client(settings.local_base_url, settings.local_api_key)
|
| 51 |
|
| 52 |
|
| 53 |
def active_model(tier: str | None = None) -> str:
|
| 54 |
-
"""Return the model name string for the given tier."""
|
| 55 |
resolved = tier or settings.active_llm_tier
|
| 56 |
-
|
| 57 |
-
"primary":
|
| 58 |
"fallback": settings.fallback_model,
|
| 59 |
-
"local":
|
| 60 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def _apply_no_think(messages: list[dict]) -> list[dict]:
|
| 64 |
-
|
| 65 |
-
Prepend /no_think to the first user message.
|
| 66 |
-
This is the Ollama-compatible way to suppress thinking mode.
|
| 67 |
-
"""
|
| 68 |
result = list(messages)
|
| 69 |
for i, msg in enumerate(result):
|
| 70 |
if msg.get("role") == "user":
|
|
@@ -74,7 +51,6 @@ def _apply_no_think(messages: list[dict]) -> list[dict]:
|
|
| 74 |
|
| 75 |
|
| 76 |
def _strip_think_tags(text: str) -> str:
|
| 77 |
-
"""Remove <think>…</think> blocks from model output."""
|
| 78 |
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
| 79 |
|
| 80 |
|
|
@@ -85,17 +61,7 @@ def chat_complete(
|
|
| 85 |
temperature: float = 0.7,
|
| 86 |
**kwargs: Any,
|
| 87 |
) -> str:
|
| 88 |
-
|
| 89 |
-
Model-agnostic chat completion. Returns the response text directly.
|
| 90 |
-
|
| 91 |
-
Thinking mode behaviour is controlled entirely by settings.thinking_mode:
|
| 92 |
-
"off" — suppress thinking via /no_think (Ollama) or extra_body (vLLM)
|
| 93 |
-
"strip" — allow thinking but remove <think> tags from the response
|
| 94 |
-
"full" — return the raw response including any <think> blocks
|
| 95 |
-
|
| 96 |
-
In local dev mode (active_llm_tier="local"), all tier requests are
|
| 97 |
-
redirected to Ollama — there is no separate fallback server locally.
|
| 98 |
-
"""
|
| 99 |
resolved_tier = tier or settings.active_llm_tier
|
| 100 |
|
| 101 |
# Local dev: no GCP server available — collapse all tiers to Ollama
|
|
@@ -107,16 +73,17 @@ def chat_complete(
|
|
| 107 |
patched_messages = messages
|
| 108 |
extra_body: dict[str, Any] = kwargs.pop("extra_body", {})
|
| 109 |
|
| 110 |
-
#
|
| 111 |
-
# like Qwen3 that think by default and need explicit suppression.
|
| 112 |
if settings.thinking_mode == "suppress":
|
| 113 |
if resolved_tier == "local":
|
| 114 |
patched_messages = _apply_no_think(messages)
|
| 115 |
else:
|
| 116 |
-
extra_body = {
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
-
#
|
| 119 |
-
# has room to reason without truncating the actual answer.
|
| 120 |
effective_max_tokens = max_tokens
|
| 121 |
if settings.thinking_mode in ("strip", "full"):
|
| 122 |
effective_max_tokens = max_tokens + settings.thinking_token_budget
|
|
@@ -129,7 +96,7 @@ def chat_complete(
|
|
| 129 |
extra_body=extra_body or None,
|
| 130 |
**kwargs,
|
| 131 |
)
|
| 132 |
-
raw = resp.choices[0].message.content or ""
|
| 133 |
|
| 134 |
if settings.thinking_mode in ("off", "strip"):
|
| 135 |
raw = _strip_think_tags(raw)
|
|
@@ -138,7 +105,6 @@ def chat_complete(
|
|
| 138 |
|
| 139 |
|
| 140 |
def warmup(tier: str | None = None) -> None:
|
| 141 |
-
"""Send a minimal prompt to pre-load the model and warm KV cache."""
|
| 142 |
chat_complete(
|
| 143 |
messages=[{"role": "user", "content": "hi"}],
|
| 144 |
max_tokens=5,
|
|
|
|
| 1 |
+
# Multi-tier LLM client — primary (vLLM) / fallback / local (Ollama), all OpenAI-compatible.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import re
|
|
|
|
| 7 |
|
| 8 |
from openai import OpenAI
|
| 9 |
|
| 10 |
+
from backend.config.settings import settings
|
| 11 |
|
| 12 |
|
| 13 |
@lru_cache(maxsize=3)
|
| 14 |
def _build_client(base_url: str, api_key: str) -> OpenAI:
|
|
|
|
| 15 |
return OpenAI(base_url=base_url, api_key=api_key)
|
| 16 |
|
| 17 |
|
| 18 |
def get_client(tier: str | None = None) -> OpenAI:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
resolved = tier or settings.active_llm_tier
|
| 20 |
|
| 21 |
if resolved == "primary":
|
| 22 |
return _build_client(settings.primary_base_url, settings.primary_api_key)
|
| 23 |
if resolved == "fallback":
|
| 24 |
+
return _build_client(settings.fallback_base_url, settings.fallback_api_key)
|
| 25 |
# local / default
|
| 26 |
return _build_client(settings.local_base_url, settings.local_api_key)
|
| 27 |
|
| 28 |
|
| 29 |
def active_model(tier: str | None = None) -> str:
|
|
|
|
| 30 |
resolved = tier or settings.active_llm_tier
|
| 31 |
+
models = {
|
| 32 |
+
"primary": settings.primary_model,
|
| 33 |
"fallback": settings.fallback_model,
|
| 34 |
+
"local": settings.local_model,
|
| 35 |
+
}
|
| 36 |
+
if resolved not in models:
|
| 37 |
+
raise ValueError(
|
| 38 |
+
f"Unknown LLM tier: '{resolved}'. Must be primary/fallback/local."
|
| 39 |
+
)
|
| 40 |
+
return models[resolved]
|
| 41 |
|
| 42 |
|
| 43 |
def _apply_no_think(messages: list[dict]) -> list[dict]:
|
| 44 |
+
# Prepend /no_think to first user message (Ollama thinking suppression).
|
|
|
|
|
|
|
|
|
|
| 45 |
result = list(messages)
|
| 46 |
for i, msg in enumerate(result):
|
| 47 |
if msg.get("role") == "user":
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
def _strip_think_tags(text: str) -> str:
|
|
|
|
| 54 |
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
| 55 |
|
| 56 |
|
|
|
|
| 61 |
temperature: float = 0.7,
|
| 62 |
**kwargs: Any,
|
| 63 |
) -> str:
|
| 64 |
+
# Returns response text. Handles thinking mode and local-tier collapsing.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
resolved_tier = tier or settings.active_llm_tier
|
| 66 |
|
| 67 |
# Local dev: no GCP server available — collapse all tiers to Ollama
|
|
|
|
| 73 |
patched_messages = messages
|
| 74 |
extra_body: dict[str, Any] = kwargs.pop("extra_body", {})
|
| 75 |
|
| 76 |
+
# Suppress thinking for models that think by default.
|
|
|
|
| 77 |
if settings.thinking_mode == "suppress":
|
| 78 |
if resolved_tier == "local":
|
| 79 |
patched_messages = _apply_no_think(messages)
|
| 80 |
else:
|
| 81 |
+
extra_body = {
|
| 82 |
+
**extra_body,
|
| 83 |
+
"chat_template_kwargs": {"enable_thinking": False},
|
| 84 |
+
}
|
| 85 |
|
| 86 |
+
# Add thinking budget when enabled.
|
|
|
|
| 87 |
effective_max_tokens = max_tokens
|
| 88 |
if settings.thinking_mode in ("strip", "full"):
|
| 89 |
effective_max_tokens = max_tokens + settings.thinking_token_budget
|
|
|
|
| 96 |
extra_body=extra_body or None,
|
| 97 |
**kwargs,
|
| 98 |
)
|
| 99 |
+
raw = (resp.choices[0].message.content if resp.choices else "") or ""
|
| 100 |
|
| 101 |
if settings.thinking_mode in ("off", "strip"):
|
| 102 |
raw = _strip_think_tags(raw)
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
def warmup(tier: str | None = None) -> None:
|
|
|
|
| 108 |
chat_complete(
|
| 109 |
messages=[{"role": "user", "content": "hi"}],
|
| 110 |
max_tokens=5,
|
{pipeline → backend/guardrails}/__init__.py
RENAMED
|
File without changes
|
{guardrails → backend/guardrails}/checks.py
RENAMED
|
@@ -1,12 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
Input and output safety guardrails.
|
| 3 |
-
|
| 4 |
-
check_input — runs BEFORE retrieval (blocks out-of-scope requests)
|
| 5 |
-
check_output — runs AFTER generation (catches persona breaks / hallucinations)
|
| 6 |
-
|
| 7 |
-
Both return a result dict so the caller decides how to handle failures
|
| 8 |
-
rather than raising exceptions inside pipeline nodes.
|
| 9 |
-
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
# ── Signal lists ───────────────────────────────────────────────────────────────
|
|
@@ -37,50 +29,43 @@ OUT_OF_SCOPE_SIGNALS = [
|
|
| 37 |
]
|
| 38 |
|
| 39 |
SAFE_FALLBACK = "I don't know."
|
| 40 |
-
OOS_FALLBACK
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
# ── Public API ─────────────────────────────────────────────────────────────────
|
| 44 |
|
| 45 |
-
def check_input(query: str) -> dict:
|
| 46 |
-
"""
|
| 47 |
-
Validate the partner's query before retrieval.
|
| 48 |
|
| 49 |
-
|
| 50 |
-
{"allowed": bool, "reason": str | None, "fallback": str | None}
|
| 51 |
-
"""
|
| 52 |
q = query.lower().strip()
|
| 53 |
|
| 54 |
if any(s in q for s in OUT_OF_SCOPE_SIGNALS):
|
| 55 |
return {"allowed": False, "reason": "out_of_scope", "fallback": OOS_FALLBACK}
|
| 56 |
|
| 57 |
if len(q) < 2:
|
| 58 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
return {"allowed": True, "reason": None, "fallback": None}
|
| 61 |
|
| 62 |
|
| 63 |
def check_output(response: str, memories: list[dict]) -> dict:
|
| 64 |
-
"""
|
| 65 |
-
Validate the generated response after generation.
|
| 66 |
-
|
| 67 |
-
Checks:
|
| 68 |
-
1. Persona break — did the model say "as an AI …"?
|
| 69 |
-
2. Basic hallucination signal — response claims facts not in memories.
|
| 70 |
-
|
| 71 |
-
Returns:
|
| 72 |
-
{"passed": bool, "issue": str | None, "fallback": str | None}
|
| 73 |
-
"""
|
| 74 |
r = response.lower()
|
| 75 |
|
| 76 |
if any(signal in r for signal in PERSONA_BREAK_SIGNALS):
|
| 77 |
return {"passed": False, "issue": "persona_break", "fallback": SAFE_FALLBACK}
|
| 78 |
|
| 79 |
-
#
|
| 80 |
-
# proper nouns that don't appear anywhere in the retrieved memories, flag it.
|
| 81 |
-
# (Full NLI-based check is handled in the evaluation pipeline, not here.)
|
| 82 |
if not memories and _makes_factual_claim(response):
|
| 83 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
return {"passed": True, "issue": None, "fallback": None}
|
| 86 |
|
|
@@ -88,11 +73,17 @@ def check_output(response: str, memories: list[dict]) -> dict:
|
|
| 88 |
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 89 |
|
| 90 |
_FACTUAL_MARKERS = [
|
| 91 |
-
" is ",
|
| 92 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
]
|
| 94 |
|
|
|
|
| 95 |
def _makes_factual_claim(text: str) -> bool:
|
| 96 |
-
"""Heuristic: does the text assert a specific fact?"""
|
| 97 |
t = text.lower()
|
| 98 |
return any(marker in t for marker in _FACTUAL_MARKERS)
|
|
|
|
| 1 |
+
# Input + output safety guardrails.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
# ── Signal lists ───────────────────────────────────────────────────────────────
|
|
|
|
| 29 |
]
|
| 30 |
|
| 31 |
SAFE_FALLBACK = "I don't know."
|
| 32 |
+
OOS_FALLBACK = (
|
| 33 |
+
"I'm here to help communicate as this person — that's a bit outside what I do."
|
| 34 |
+
)
|
| 35 |
|
| 36 |
|
| 37 |
# ── Public API ─────────────────────────────────────────────────────────────────
|
| 38 |
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
def check_input(query: str) -> dict:
|
|
|
|
|
|
|
| 41 |
q = query.lower().strip()
|
| 42 |
|
| 43 |
if any(s in q for s in OUT_OF_SCOPE_SIGNALS):
|
| 44 |
return {"allowed": False, "reason": "out_of_scope", "fallback": OOS_FALLBACK}
|
| 45 |
|
| 46 |
if len(q) < 2:
|
| 47 |
+
return {
|
| 48 |
+
"allowed": False,
|
| 49 |
+
"reason": "empty_query",
|
| 50 |
+
"fallback": "Could you repeat that?",
|
| 51 |
+
}
|
| 52 |
|
| 53 |
return {"allowed": True, "reason": None, "fallback": None}
|
| 54 |
|
| 55 |
|
| 56 |
def check_output(response: str, memories: list[dict]) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
r = response.lower()
|
| 58 |
|
| 59 |
if any(signal in r for signal in PERSONA_BREAK_SIGNALS):
|
| 60 |
return {"passed": False, "issue": "persona_break", "fallback": SAFE_FALLBACK}
|
| 61 |
|
| 62 |
+
# Flag unsupported factual claims when no memories were retrieved.
|
|
|
|
|
|
|
| 63 |
if not memories and _makes_factual_claim(response):
|
| 64 |
+
return {
|
| 65 |
+
"passed": False,
|
| 66 |
+
"issue": "unsupported_claim",
|
| 67 |
+
"fallback": SAFE_FALLBACK,
|
| 68 |
+
}
|
| 69 |
|
| 70 |
return {"passed": True, "issue": None, "fallback": None}
|
| 71 |
|
|
|
|
| 73 |
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 74 |
|
| 75 |
_FACTUAL_MARKERS = [
|
| 76 |
+
" is ",
|
| 77 |
+
" was ",
|
| 78 |
+
" has ",
|
| 79 |
+
" have ",
|
| 80 |
+
" lives in ",
|
| 81 |
+
" born in ",
|
| 82 |
+
" works at ",
|
| 83 |
+
" studied at ",
|
| 84 |
]
|
| 85 |
|
| 86 |
+
|
| 87 |
def _makes_factual_claim(text: str) -> bool:
|
|
|
|
| 88 |
t = text.lower()
|
| 89 |
return any(marker in t for marker in _FACTUAL_MARKERS)
|
main.py → backend/main.py
RENAMED
|
@@ -1,18 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
CLI entry point — thin wrapper around the LangGraph pipeline.
|
| 3 |
-
|
| 4 |
-
Usage:
|
| 5 |
-
python main.py # interactive chat, local LLM tier
|
| 6 |
-
python main.py --user mia_chen # skip persona selection prompt
|
| 7 |
-
python main.py --debug # print per-turn latency table
|
| 8 |
-
python main.py --fast # skip LLM intent call (keyword routing),
|
| 9 |
-
# cuts turn time from ~2min → ~45s on M2 Mac
|
| 10 |
-
python main.py --tier primary # override LLM tier
|
| 11 |
-
|
| 12 |
-
For the full UI, run the FastAPI + Streamlit stack instead:
|
| 13 |
-
uvicorn api.main:app --reload
|
| 14 |
-
streamlit run ui/app.py
|
| 15 |
-
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import argparse
|
|
@@ -21,34 +7,45 @@ import os
|
|
| 21 |
import sys
|
| 22 |
import time
|
| 23 |
|
| 24 |
-
from config.settings import settings
|
| 25 |
-
from guardrails.checks import check_input
|
| 26 |
-
from pipeline.graph import aac_graph
|
| 27 |
-
from pipeline.state import
|
| 28 |
-
from retrieval.bucket_priors import uniform_priors
|
| 29 |
-
from retrieval.vector_store import _get_embedder, _get_reranker
|
| 30 |
|
| 31 |
|
| 32 |
def parse_args() -> argparse.Namespace:
|
| 33 |
p = argparse.ArgumentParser(description="AAC Chatbot CLI")
|
| 34 |
-
p.add_argument("--user",
|
| 35 |
-
p.add_argument("--debug", action="store_true",
|
| 36 |
-
p.add_argument(
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
return p.parse_args()
|
| 42 |
|
| 43 |
|
| 44 |
# ── Fast keyword-based intent routing (bypasses the slow LLM intent call) ──────
|
| 45 |
|
|
|
|
| 46 |
def _keyword_intent(query: str) -> tuple[dict, GenerationConfig]:
|
| 47 |
"""Replicate milestone-1 keyword routing as a fast local-dev shortcut."""
|
| 48 |
q = query.lower()
|
| 49 |
bucket: str | None = None
|
| 50 |
|
| 51 |
-
if any(
|
|
|
|
|
|
|
|
|
|
| 52 |
bucket = "medical"
|
| 53 |
elif any(w in q for w in ["family", "mom", "dad", "brother", "sister", "parents"]):
|
| 54 |
bucket = "family"
|
|
@@ -59,12 +56,27 @@ def _keyword_intent(query: str) -> tuple[dict, GenerationConfig]:
|
|
| 59 |
elif any(w in q for w in ["friend", "social", "people", "party", "community"]):
|
| 60 |
bucket = "social"
|
| 61 |
|
| 62 |
-
intent_type =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
route = {
|
| 65 |
-
"sub_intents": [
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"affect": "NEUTRAL",
|
| 69 |
}
|
| 70 |
gen_config: GenerationConfig = {
|
|
@@ -92,17 +104,17 @@ def select_user(users: dict[str, dict], user_arg: str | None) -> str:
|
|
| 92 |
print(f" {uid:20s} — {u['name']} ({u['condition']})")
|
| 93 |
uid = input("\nSelect user id: ").strip()
|
| 94 |
if uid not in users:
|
| 95 |
-
print(
|
| 96 |
sys.exit(1)
|
| 97 |
return uid
|
| 98 |
|
| 99 |
|
| 100 |
def print_latency(log: dict, turn: int) -> None:
|
| 101 |
fields = ["t_sensing", "t_intent", "t_retrieval", "t_generation", "t_total"]
|
| 102 |
-
labels = ["sensing",
|
| 103 |
-
vals
|
| 104 |
widths = [max(len(l), len(v)) for l, v in zip(labels, vals)]
|
| 105 |
-
sep
|
| 106 |
print(f"\n[turn {turn} latency]")
|
| 107 |
print(sep.join(l.ljust(w) for l, w in zip(labels, widths)))
|
| 108 |
print(sep.join(v.ljust(w) for v, w in zip(vals, widths)))
|
|
@@ -114,7 +126,6 @@ def main() -> None:
|
|
| 114 |
# Optionally override the LLM tier at runtime
|
| 115 |
if args.tier:
|
| 116 |
os.environ["ACTIVE_LLM_TIER"] = args.tier
|
| 117 |
-
settings.active_llm_tier = args.tier
|
| 118 |
|
| 119 |
users = load_users()
|
| 120 |
user_id = select_user(users, args.user)
|
|
@@ -152,14 +163,13 @@ def main() -> None:
|
|
| 152 |
turn_id += 1
|
| 153 |
|
| 154 |
# --fast: resolve intent via keywords, skip the slow LLM intent node
|
| 155 |
-
pre_route, pre_gen_config = (
|
| 156 |
-
_keyword_intent(query) if args.fast else (None, None)
|
| 157 |
-
)
|
| 158 |
t_intent_fast = 0.0
|
| 159 |
if args.fast:
|
| 160 |
t0 = time.perf_counter()
|
| 161 |
-
_keyword_intent(query)
|
| 162 |
t_intent_fast = time.perf_counter() - t0
|
|
|
|
|
|
|
| 163 |
|
| 164 |
state = PipelineState(
|
| 165 |
user_id=user_id,
|
|
@@ -171,7 +181,7 @@ def main() -> None:
|
|
| 171 |
gaze_bucket=None,
|
| 172 |
air_written_text=None,
|
| 173 |
raw_query=query,
|
| 174 |
-
intent_route=pre_route,
|
| 175 |
generation_config=pre_gen_config,
|
| 176 |
retrieved_chunks=[],
|
| 177 |
bucket_priors=bucket_priors,
|
|
@@ -180,8 +190,13 @@ def main() -> None:
|
|
| 180 |
candidates=[],
|
| 181 |
selected_response=None,
|
| 182 |
llm_tier_used="",
|
| 183 |
-
latency_log={
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
mlflow_run_id=None,
|
| 186 |
guardrail_passed=True,
|
| 187 |
)
|
|
@@ -191,13 +206,15 @@ def main() -> None:
|
|
| 191 |
print(f"AAC Bot: {result['selected_response']}\n")
|
| 192 |
|
| 193 |
session_history = result["session_history"]
|
| 194 |
-
bucket_priors
|
| 195 |
|
| 196 |
if args.debug:
|
| 197 |
print_latency(result.get("latency_log") or {}, turn_id)
|
| 198 |
-
print(
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
| 201 |
|
| 202 |
|
| 203 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
# CLI entry point for the AAC chatbot pipeline.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import argparse
|
|
|
|
| 7 |
import sys
|
| 8 |
import time
|
| 9 |
|
| 10 |
+
from backend.config.settings import settings
|
| 11 |
+
from backend.guardrails.checks import check_input
|
| 12 |
+
from backend.pipeline.graph import aac_graph
|
| 13 |
+
from backend.pipeline.state import GenerationConfig, PipelineState
|
| 14 |
+
from backend.retrieval.bucket_priors import uniform_priors
|
| 15 |
+
from backend.retrieval.vector_store import _get_embedder, _get_reranker
|
| 16 |
|
| 17 |
|
| 18 |
def parse_args() -> argparse.Namespace:
|
| 19 |
p = argparse.ArgumentParser(description="AAC Chatbot CLI")
|
| 20 |
+
p.add_argument("--user", type=str, default=None, help="Persona user_id")
|
| 21 |
+
p.add_argument("--debug", action="store_true", help="Print latency table each turn")
|
| 22 |
+
p.add_argument(
|
| 23 |
+
"--fast",
|
| 24 |
+
action="store_true",
|
| 25 |
+
help="Skip LLM intent call — use keyword routing instead (faster local dev)",
|
| 26 |
+
)
|
| 27 |
+
p.add_argument(
|
| 28 |
+
"--tier",
|
| 29 |
+
type=str,
|
| 30 |
+
default=None,
|
| 31 |
+
choices=["primary", "fallback", "local"],
|
| 32 |
+
help="Override LLM tier (default: settings.active_llm_tier)",
|
| 33 |
+
)
|
| 34 |
return p.parse_args()
|
| 35 |
|
| 36 |
|
| 37 |
# ── Fast keyword-based intent routing (bypasses the slow LLM intent call) ──────
|
| 38 |
|
| 39 |
+
|
| 40 |
def _keyword_intent(query: str) -> tuple[dict, GenerationConfig]:
|
| 41 |
"""Replicate milestone-1 keyword routing as a fast local-dev shortcut."""
|
| 42 |
q = query.lower()
|
| 43 |
bucket: str | None = None
|
| 44 |
|
| 45 |
+
if any(
|
| 46 |
+
w in q
|
| 47 |
+
for w in ["medication", "medicine", "doctor", "health", "allergic", "therapy"]
|
| 48 |
+
):
|
| 49 |
bucket = "medical"
|
| 50 |
elif any(w in q for w in ["family", "mom", "dad", "brother", "sister", "parents"]):
|
| 51 |
bucket = "family"
|
|
|
|
| 56 |
elif any(w in q for w in ["friend", "social", "people", "party", "community"]):
|
| 57 |
bucket = "social"
|
| 58 |
|
| 59 |
+
intent_type = (
|
| 60 |
+
"CONTEXTUAL"
|
| 61 |
+
if any(w in q for w in ["you just said", "earlier", "you mentioned"])
|
| 62 |
+
else "PERSONAL"
|
| 63 |
+
)
|
| 64 |
|
| 65 |
route = {
|
| 66 |
+
"sub_intents": [
|
| 67 |
+
{
|
| 68 |
+
"type": intent_type,
|
| 69 |
+
"query": query,
|
| 70 |
+
"bucket_hint": bucket,
|
| 71 |
+
"priority": "normal",
|
| 72 |
+
}
|
| 73 |
+
],
|
| 74 |
+
"style_constraints": {
|
| 75 |
+
"tone_tag": "[TONE:DEFAULT]",
|
| 76 |
+
"max_tokens": 100,
|
| 77 |
+
"retrieval_mode": "full",
|
| 78 |
+
"persona_mod": "baseline",
|
| 79 |
+
},
|
| 80 |
"affect": "NEUTRAL",
|
| 81 |
}
|
| 82 |
gen_config: GenerationConfig = {
|
|
|
|
| 104 |
print(f" {uid:20s} — {u['name']} ({u['condition']})")
|
| 105 |
uid = input("\nSelect user id: ").strip()
|
| 106 |
if uid not in users:
|
| 107 |
+
print("Invalid id.")
|
| 108 |
sys.exit(1)
|
| 109 |
return uid
|
| 110 |
|
| 111 |
|
| 112 |
def print_latency(log: dict, turn: int) -> None:
|
| 113 |
fields = ["t_sensing", "t_intent", "t_retrieval", "t_generation", "t_total"]
|
| 114 |
+
labels = ["sensing", "intent", "retrieval", "generation", "TOTAL"]
|
| 115 |
+
vals = [f"{log.get(f, 0):.3f}s" for f in fields]
|
| 116 |
widths = [max(len(l), len(v)) for l, v in zip(labels, vals)]
|
| 117 |
+
sep = " | "
|
| 118 |
print(f"\n[turn {turn} latency]")
|
| 119 |
print(sep.join(l.ljust(w) for l, w in zip(labels, widths)))
|
| 120 |
print(sep.join(v.ljust(w) for v, w in zip(vals, widths)))
|
|
|
|
| 126 |
# Optionally override the LLM tier at runtime
|
| 127 |
if args.tier:
|
| 128 |
os.environ["ACTIVE_LLM_TIER"] = args.tier
|
|
|
|
| 129 |
|
| 130 |
users = load_users()
|
| 131 |
user_id = select_user(users, args.user)
|
|
|
|
| 163 |
turn_id += 1
|
| 164 |
|
| 165 |
# --fast: resolve intent via keywords, skip the slow LLM intent node
|
|
|
|
|
|
|
|
|
|
| 166 |
t_intent_fast = 0.0
|
| 167 |
if args.fast:
|
| 168 |
t0 = time.perf_counter()
|
| 169 |
+
pre_route, pre_gen_config = _keyword_intent(query)
|
| 170 |
t_intent_fast = time.perf_counter() - t0
|
| 171 |
+
else:
|
| 172 |
+
pre_route, pre_gen_config = None, None
|
| 173 |
|
| 174 |
state = PipelineState(
|
| 175 |
user_id=user_id,
|
|
|
|
| 181 |
gaze_bucket=None,
|
| 182 |
air_written_text=None,
|
| 183 |
raw_query=query,
|
| 184 |
+
intent_route=pre_route, # pre-filled → intent node sees it and skips LLM call
|
| 185 |
generation_config=pre_gen_config,
|
| 186 |
retrieved_chunks=[],
|
| 187 |
bucket_priors=bucket_priors,
|
|
|
|
| 190 |
candidates=[],
|
| 191 |
selected_response=None,
|
| 192 |
llm_tier_used="",
|
| 193 |
+
latency_log={
|
| 194 |
+
"t_sensing": 0.0,
|
| 195 |
+
"t_intent": round(t_intent_fast, 4),
|
| 196 |
+
"t_retrieval": 0.0,
|
| 197 |
+
"t_generation": 0.0,
|
| 198 |
+
"t_total": 0.0,
|
| 199 |
+
},
|
| 200 |
mlflow_run_id=None,
|
| 201 |
guardrail_passed=True,
|
| 202 |
)
|
|
|
|
| 206 |
print(f"AAC Bot: {result['selected_response']}\n")
|
| 207 |
|
| 208 |
session_history = result["session_history"]
|
| 209 |
+
bucket_priors = result["bucket_priors"]
|
| 210 |
|
| 211 |
if args.debug:
|
| 212 |
print_latency(result.get("latency_log") or {}, turn_id)
|
| 213 |
+
print(
|
| 214 |
+
f" tier={result.get('llm_tier_used')} | "
|
| 215 |
+
f"retrieval={result.get('retrieval_mode_used')} | "
|
| 216 |
+
f"affect={(result.get('affect') or {}).get('emotion', '?')}\n"
|
| 217 |
+
)
|
| 218 |
|
| 219 |
|
| 220 |
if __name__ == "__main__":
|
{pipeline/nodes → backend/pipeline}/__init__.py
RENAMED
|
File without changes
|
{pipeline → backend/pipeline}/graph.py
RENAMED
|
@@ -1,15 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
intent ──► [affect check] ──► fast_retrieval ──► [latency check] ──► fallback_gen ──► feedback
|
| 7 |
-
└──► full_retrieval ──► [latency check] ──► primary_gen ──► feedback
|
| 8 |
-
"""
|
| 9 |
-
from langgraph.graph import StateGraph, END
|
| 10 |
-
|
| 11 |
-
from pipeline.state import PipelineState
|
| 12 |
-
from pipeline.nodes import intent, retrieval, planner, feedback
|
| 13 |
|
| 14 |
|
| 15 |
def _route_by_affect(state: PipelineState) -> str:
|
|
@@ -20,7 +13,8 @@ def _route_by_affect(state: PipelineState) -> str:
|
|
| 20 |
|
| 21 |
def _route_by_latency(state: PipelineState) -> str:
|
| 22 |
"""Conditional edge: if cumulative latency > threshold, use fallback LLM."""
|
| 23 |
-
from config.settings import settings
|
|
|
|
| 24 |
log = state.get("latency_log") or {}
|
| 25 |
elapsed = log.get("t_intent", 0.0) + log.get("t_retrieval", 0.0)
|
| 26 |
return "fallback" if elapsed > settings.fallback_latency_threshold else "primary"
|
|
@@ -30,12 +24,12 @@ def build_graph() -> StateGraph:
|
|
| 30 |
graph = StateGraph(PipelineState)
|
| 31 |
|
| 32 |
# ── Nodes ──────────────────────────────────────────────────────────────────
|
| 33 |
-
graph.add_node("intent",
|
| 34 |
graph.add_node("fast_retrieval", retrieval.run_fast)
|
| 35 |
graph.add_node("full_retrieval", retrieval.run_full)
|
| 36 |
-
graph.add_node("primary_gen",
|
| 37 |
-
graph.add_node("fallback_gen",
|
| 38 |
-
graph.add_node("feedback",
|
| 39 |
|
| 40 |
# ── Entry ──────────────────────────────────────────────────────────────────
|
| 41 |
graph.set_entry_point("intent")
|
|
@@ -60,9 +54,9 @@ def build_graph() -> StateGraph:
|
|
| 60 |
)
|
| 61 |
|
| 62 |
# ── Feedback loop ─────────────────────────────────────────────────────────
|
| 63 |
-
graph.add_edge("primary_gen",
|
| 64 |
graph.add_edge("fallback_gen", "feedback")
|
| 65 |
-
graph.add_edge("feedback",
|
| 66 |
|
| 67 |
return graph.compile()
|
| 68 |
|
|
|
|
| 1 |
+
# LangGraph pipeline graph — intent → retrieval → generation → feedback.
|
| 2 |
+
from langgraph.graph import END, StateGraph
|
| 3 |
|
| 4 |
+
from backend.pipeline.nodes import feedback, intent, planner, retrieval
|
| 5 |
+
from backend.pipeline.state import PipelineState
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def _route_by_affect(state: PipelineState) -> str:
|
|
|
|
| 13 |
|
| 14 |
def _route_by_latency(state: PipelineState) -> str:
|
| 15 |
"""Conditional edge: if cumulative latency > threshold, use fallback LLM."""
|
| 16 |
+
from backend.config.settings import settings
|
| 17 |
+
|
| 18 |
log = state.get("latency_log") or {}
|
| 19 |
elapsed = log.get("t_intent", 0.0) + log.get("t_retrieval", 0.0)
|
| 20 |
return "fallback" if elapsed > settings.fallback_latency_threshold else "primary"
|
|
|
|
| 24 |
graph = StateGraph(PipelineState)
|
| 25 |
|
| 26 |
# ── Nodes ──────────────────────────────────────────────────────────────────
|
| 27 |
+
graph.add_node("intent", intent.run)
|
| 28 |
graph.add_node("fast_retrieval", retrieval.run_fast)
|
| 29 |
graph.add_node("full_retrieval", retrieval.run_full)
|
| 30 |
+
graph.add_node("primary_gen", planner.run_primary)
|
| 31 |
+
graph.add_node("fallback_gen", planner.run_fallback)
|
| 32 |
+
graph.add_node("feedback", feedback.run)
|
| 33 |
|
| 34 |
# ── Entry ──────────────────────────────────────────────────────────────────
|
| 35 |
graph.set_entry_point("intent")
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
# ── Feedback loop ─────────────────────────────────────────────────────────
|
| 57 |
+
graph.add_edge("primary_gen", "feedback")
|
| 58 |
graph.add_edge("fallback_gen", "feedback")
|
| 59 |
+
graph.add_edge("feedback", END)
|
| 60 |
|
| 61 |
return graph.compile()
|
| 62 |
|
{retrieval → backend/pipeline/nodes}/__init__.py
RENAMED
|
File without changes
|
{pipeline → backend/pipeline}/nodes/feedback.py
RENAMED
|
@@ -1,29 +1,16 @@
|
|
| 1 |
-
|
| 2 |
-
L5 — Feedback Loop node.
|
| 3 |
-
|
| 4 |
-
After a response is accepted:
|
| 5 |
-
1. Log the full turn to MLflow (latency, metrics, prompt version, tier used)
|
| 6 |
-
2. Update session-level Bayesian bucket priors
|
| 7 |
-
3. Append the accepted turn to session history
|
| 8 |
-
|
| 9 |
-
Rejected candidates are also logged for offline analysis.
|
| 10 |
-
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
-
import
|
| 14 |
-
import
|
| 15 |
-
|
| 16 |
-
import mlflow
|
| 17 |
-
|
| 18 |
-
from config.settings import settings
|
| 19 |
-
from pipeline.state import PipelineState
|
| 20 |
-
from retrieval.bucket_priors import update_priors
|
| 21 |
|
| 22 |
|
| 23 |
def run(state: PipelineState) -> dict:
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 27 |
updated_priors = _update_bucket_priors(state)
|
| 28 |
updated_history = _append_turn_to_history(state)
|
| 29 |
|
|
@@ -36,7 +23,10 @@ def run(state: PipelineState) -> dict:
|
|
| 36 |
|
| 37 |
# ── MLflow logging ─────────────────────────────────────────────────────────────
|
| 38 |
|
|
|
|
| 39 |
def _log_to_mlflow(state: PipelineState) -> str:
|
|
|
|
|
|
|
| 40 |
mlflow.set_tracking_uri(settings.mlflow_tracking_uri)
|
| 41 |
mlflow.set_experiment(settings.mlflow_experiment)
|
| 42 |
|
|
@@ -44,22 +34,26 @@ def _log_to_mlflow(state: PipelineState) -> str:
|
|
| 44 |
affect = (state.get("affect") or {}).get("emotion", "UNKNOWN")
|
| 45 |
|
| 46 |
with mlflow.start_run(run_name=f"turn-{state['turn_id']}") as run:
|
| 47 |
-
mlflow.log_params(
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
# Log the selected response as artifact text for qualitative review
|
| 65 |
mlflow.log_text(
|
|
@@ -72,6 +66,7 @@ def _log_to_mlflow(state: PipelineState) -> str:
|
|
| 72 |
|
| 73 |
# ── Bayesian bucket prior update ───────────────────────────────────────────────
|
| 74 |
|
|
|
|
| 75 |
def _update_bucket_priors(state: PipelineState) -> dict[str, float]:
|
| 76 |
chunks = state.get("retrieved_chunks") or []
|
| 77 |
if not chunks:
|
|
@@ -90,9 +85,9 @@ def _update_bucket_priors(state: PipelineState) -> dict[str, float]:
|
|
| 90 |
|
| 91 |
# ── Session history append ─────────────────────────────────────────────────────
|
| 92 |
|
|
|
|
| 93 |
def _append_turn_to_history(state: PipelineState) -> list[dict]:
|
| 94 |
-
"""Returns a single-element list; LangGraph's Annotated[list, add] merges it."""
|
| 95 |
return [
|
| 96 |
-
{"role": "partner",
|
| 97 |
{"role": "aac_user", "content": state.get("selected_response") or ""},
|
| 98 |
]
|
|
|
|
| 1 |
+
# Feedback node — MLflow logging, bucket prior update, history append.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from backend.config.settings import settings
|
| 5 |
+
from backend.pipeline.state import PipelineState
|
| 6 |
+
from backend.retrieval.bucket_priors import update_priors
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
def run(state: PipelineState) -> dict:
|
| 10 |
+
try:
|
| 11 |
+
mlflow_run_id = _log_to_mlflow(state)
|
| 12 |
+
except Exception:
|
| 13 |
+
mlflow_run_id = None
|
| 14 |
updated_priors = _update_bucket_priors(state)
|
| 15 |
updated_history = _append_turn_to_history(state)
|
| 16 |
|
|
|
|
| 23 |
|
| 24 |
# ── MLflow logging ─────────────────────────────────────────────────────────────
|
| 25 |
|
| 26 |
+
|
| 27 |
def _log_to_mlflow(state: PipelineState) -> str:
|
| 28 |
+
import mlflow
|
| 29 |
+
|
| 30 |
mlflow.set_tracking_uri(settings.mlflow_tracking_uri)
|
| 31 |
mlflow.set_experiment(settings.mlflow_experiment)
|
| 32 |
|
|
|
|
| 34 |
affect = (state.get("affect") or {}).get("emotion", "UNKNOWN")
|
| 35 |
|
| 36 |
with mlflow.start_run(run_name=f"turn-{state['turn_id']}") as run:
|
| 37 |
+
mlflow.log_params(
|
| 38 |
+
{
|
| 39 |
+
"user_id": state["user_id"],
|
| 40 |
+
"turn_id": state["turn_id"],
|
| 41 |
+
"llm_tier": state.get("llm_tier_used", "unknown"),
|
| 42 |
+
"retrieval_mode": state.get("retrieval_mode_used", "unknown"),
|
| 43 |
+
"affect": affect,
|
| 44 |
+
"guardrail_passed": state.get("guardrail_passed", True),
|
| 45 |
+
}
|
| 46 |
+
)
|
| 47 |
+
mlflow.log_metrics(
|
| 48 |
+
{
|
| 49 |
+
"t_sensing": latency.get("t_sensing", 0.0),
|
| 50 |
+
"t_intent": latency.get("t_intent", 0.0),
|
| 51 |
+
"t_retrieval": latency.get("t_retrieval", 0.0),
|
| 52 |
+
"t_generation": latency.get("t_generation", 0.0),
|
| 53 |
+
"t_total": latency.get("t_total", 0.0),
|
| 54 |
+
"num_chunks": float(len(state.get("retrieved_chunks") or [])),
|
| 55 |
+
}
|
| 56 |
+
)
|
| 57 |
|
| 58 |
# Log the selected response as artifact text for qualitative review
|
| 59 |
mlflow.log_text(
|
|
|
|
| 66 |
|
| 67 |
# ── Bayesian bucket prior update ───────────────────────────────────────────────
|
| 68 |
|
| 69 |
+
|
| 70 |
def _update_bucket_priors(state: PipelineState) -> dict[str, float]:
|
| 71 |
chunks = state.get("retrieved_chunks") or []
|
| 72 |
if not chunks:
|
|
|
|
| 85 |
|
| 86 |
# ── Session history append ─────────────────────────────────────────────────────
|
| 87 |
|
| 88 |
+
|
| 89 |
def _append_turn_to_history(state: PipelineState) -> list[dict]:
|
|
|
|
| 90 |
return [
|
| 91 |
+
{"role": "partner", "content": state["raw_query"]},
|
| 92 |
{"role": "aac_user", "content": state.get("selected_response") or ""},
|
| 93 |
]
|
{pipeline → backend/pipeline}/nodes/intent.py
RENAMED
|
@@ -1,20 +1,15 @@
|
|
| 1 |
-
|
| 2 |
-
L2 — Agentic Intent Decomposition node.
|
| 3 |
-
|
| 4 |
-
Receives the partner query + affect state, calls the controller LLM once
|
| 5 |
-
(non-thinking mode, ReAct style), and returns a Pydantic-validated
|
| 6 |
-
IntentRoute that drives all downstream routing decisions.
|
| 7 |
-
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import re
|
| 11 |
import time
|
| 12 |
-
from typing import Literal
|
| 13 |
|
| 14 |
from pydantic import BaseModel
|
| 15 |
-
|
| 16 |
-
from
|
| 17 |
-
from
|
|
|
|
| 18 |
|
| 19 |
# ── Pydantic output schemas ────────────────────────────────────────────────────
|
| 20 |
|
|
@@ -25,15 +20,17 @@ AffectEmotion = Literal["HAPPY", "FRUSTRATED", "NEUTRAL", "SURPRISED"]
|
|
| 25 |
class SubIntentSchema(BaseModel):
|
| 26 |
type: Literal["PERSONAL", "CONTEXTUAL", "OPEN_DOMAIN"]
|
| 27 |
query: str
|
| 28 |
-
bucket_hint:
|
| 29 |
priority: Literal["fast", "normal"] = "normal"
|
| 30 |
|
| 31 |
|
| 32 |
class StyleConfig(BaseModel):
|
| 33 |
-
tone_tag: str
|
| 34 |
max_tokens: int
|
| 35 |
-
retrieval_mode: str
|
| 36 |
-
persona_mod:
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
class IntentRouteSchema(BaseModel):
|
|
@@ -42,7 +39,7 @@ class IntentRouteSchema(BaseModel):
|
|
| 42 |
affect: AffectEmotion
|
| 43 |
|
| 44 |
|
| 45 |
-
# ── Affect → generation config mapping
|
| 46 |
|
| 47 |
_AFFECT_CONFIG: dict[str, GenerationConfig] = {
|
| 48 |
"HAPPY": {
|
|
@@ -91,24 +88,30 @@ Respond ONLY with valid JSON matching the IntentRoute schema. No extra text.
|
|
| 91 |
"""
|
| 92 |
|
| 93 |
|
| 94 |
-
def _build_user_prompt(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
return (
|
| 96 |
f"Persona: {persona_name}\n"
|
| 97 |
f"Affect: {affect}\n"
|
| 98 |
-
f"Partner query: {query}\n\n"
|
| 99 |
"Produce the IntentRoute JSON:"
|
| 100 |
)
|
| 101 |
|
| 102 |
|
| 103 |
# ── Node entry point ───────────────────────────────────────────────────────────
|
| 104 |
|
|
|
|
| 105 |
def run(state: PipelineState) -> dict:
|
| 106 |
"""LangGraph node: intent decomposition."""
|
| 107 |
t0 = time.perf_counter()
|
| 108 |
|
| 109 |
# --fast mode: intent_route already resolved by keyword routing in main.py
|
| 110 |
if state.get("intent_route") and state.get("generation_config"):
|
| 111 |
-
return {}
|
| 112 |
|
| 113 |
affect_state = state.get("affect") or {}
|
| 114 |
emotion: str = affect_state.get("emotion", "NEUTRAL")
|
|
@@ -123,10 +126,23 @@ def run(state: PipelineState) -> dict:
|
|
| 123 |
for attempt in range(3): # LangGraph retry logic (up to 2 retries)
|
| 124 |
messages = [
|
| 125 |
{"role": "system", "content": _SYSTEM_PROMPT},
|
| 126 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
]
|
| 128 |
if attempt > 0:
|
| 129 |
-
messages.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
raw = chat_complete(
|
| 132 |
messages=messages,
|
|
@@ -151,7 +167,14 @@ def run(state: PipelineState) -> dict:
|
|
| 151 |
if route is None:
|
| 152 |
# Hard fallback: treat as a single PERSONAL intent, full retrieval
|
| 153 |
route = {
|
| 154 |
-
"sub_intents": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
"style_constraints": gen_config,
|
| 156 |
"affect": emotion,
|
| 157 |
}
|
|
@@ -166,5 +189,3 @@ def run(state: PipelineState) -> dict:
|
|
| 166 |
"generation_config": gen_config,
|
| 167 |
"latency_log": latency_log,
|
| 168 |
}
|
| 169 |
-
|
| 170 |
-
|
|
|
|
| 1 |
+
# Intent decomposition node — LLM-based query classification and routing.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import re
|
| 5 |
import time
|
| 6 |
+
from typing import Literal
|
| 7 |
|
| 8 |
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from backend.config.settings import settings
|
| 11 |
+
from backend.generation.llm_client import chat_complete
|
| 12 |
+
from backend.pipeline.state import GenerationConfig, IntentRoute, PipelineState
|
| 13 |
|
| 14 |
# ── Pydantic output schemas ────────────────────────────────────────────────────
|
| 15 |
|
|
|
|
| 20 |
class SubIntentSchema(BaseModel):
|
| 21 |
type: Literal["PERSONAL", "CONTEXTUAL", "OPEN_DOMAIN"]
|
| 22 |
query: str
|
| 23 |
+
bucket_hint: BucketType | None = None
|
| 24 |
priority: Literal["fast", "normal"] = "normal"
|
| 25 |
|
| 26 |
|
| 27 |
class StyleConfig(BaseModel):
|
| 28 |
+
tone_tag: str # e.g. "[TONE:WITTY_SARCASTIC]"
|
| 29 |
max_tokens: int
|
| 30 |
+
retrieval_mode: str # "fast" | "full"
|
| 31 |
+
persona_mod: (
|
| 32 |
+
str # "amplify_quirks" | "suppress_humor" | "baseline" | "add_confirmation"
|
| 33 |
+
)
|
| 34 |
|
| 35 |
|
| 36 |
class IntentRouteSchema(BaseModel):
|
|
|
|
| 39 |
affect: AffectEmotion
|
| 40 |
|
| 41 |
|
| 42 |
+
# ── Affect → generation config mapping ────────────────────────────────────────
|
| 43 |
|
| 44 |
_AFFECT_CONFIG: dict[str, GenerationConfig] = {
|
| 45 |
"HAPPY": {
|
|
|
|
| 88 |
"""
|
| 89 |
|
| 90 |
|
| 91 |
+
def _build_user_prompt(
|
| 92 |
+
query: str, affect: str, persona_name: str, air_written_text: str | None = None
|
| 93 |
+
) -> str:
|
| 94 |
+
air_note = (
|
| 95 |
+
f'\nAir-written supplement: "{air_written_text}"' if air_written_text else ""
|
| 96 |
+
)
|
| 97 |
return (
|
| 98 |
f"Persona: {persona_name}\n"
|
| 99 |
f"Affect: {affect}\n"
|
| 100 |
+
f"Partner query: {query}{air_note}\n\n"
|
| 101 |
"Produce the IntentRoute JSON:"
|
| 102 |
)
|
| 103 |
|
| 104 |
|
| 105 |
# ── Node entry point ───────────────────────────────────────────────────────────
|
| 106 |
|
| 107 |
+
|
| 108 |
def run(state: PipelineState) -> dict:
|
| 109 |
"""LangGraph node: intent decomposition."""
|
| 110 |
t0 = time.perf_counter()
|
| 111 |
|
| 112 |
# --fast mode: intent_route already resolved by keyword routing in main.py
|
| 113 |
if state.get("intent_route") and state.get("generation_config"):
|
| 114 |
+
return {} # nothing to update — downstream nodes use the pre-filled values
|
| 115 |
|
| 116 |
affect_state = state.get("affect") or {}
|
| 117 |
emotion: str = affect_state.get("emotion", "NEUTRAL")
|
|
|
|
| 126 |
for attempt in range(3): # LangGraph retry logic (up to 2 retries)
|
| 127 |
messages = [
|
| 128 |
{"role": "system", "content": _SYSTEM_PROMPT},
|
| 129 |
+
{
|
| 130 |
+
"role": "user",
|
| 131 |
+
"content": _build_user_prompt(
|
| 132 |
+
query,
|
| 133 |
+
emotion,
|
| 134 |
+
persona_name,
|
| 135 |
+
air_written_text=state.get("air_written_text"),
|
| 136 |
+
),
|
| 137 |
+
},
|
| 138 |
]
|
| 139 |
if attempt > 0:
|
| 140 |
+
messages.append(
|
| 141 |
+
{
|
| 142 |
+
"role": "user",
|
| 143 |
+
"content": f"Validation error: {last_error}. Fix and retry.",
|
| 144 |
+
}
|
| 145 |
+
)
|
| 146 |
|
| 147 |
raw = chat_complete(
|
| 148 |
messages=messages,
|
|
|
|
| 167 |
if route is None:
|
| 168 |
# Hard fallback: treat as a single PERSONAL intent, full retrieval
|
| 169 |
route = {
|
| 170 |
+
"sub_intents": [
|
| 171 |
+
{
|
| 172 |
+
"type": "PERSONAL",
|
| 173 |
+
"query": query,
|
| 174 |
+
"bucket_hint": None,
|
| 175 |
+
"priority": "normal",
|
| 176 |
+
}
|
| 177 |
+
],
|
| 178 |
"style_constraints": gen_config,
|
| 179 |
"affect": emotion,
|
| 180 |
}
|
|
|
|
| 189 |
"generation_config": gen_config,
|
| 190 |
"latency_log": latency_log,
|
| 191 |
}
|
|
|
|
|
|
{pipeline → backend/pipeline}/nodes/planner.py
RENAMED
|
@@ -1,39 +1,28 @@
|
|
| 1 |
-
|
| 2 |
-
L4 — Dialogue Planning & Generation node.
|
| 3 |
-
|
| 4 |
-
Expression-conditioned response shaping (proposal §5.5):
|
| 5 |
-
1. Build augmented prompt (persona profile + retrieved evidence + affect config + style exemplar)
|
| 6 |
-
2. Generate N candidate responses
|
| 7 |
-
3. Rank candidates by composite score: α·faithful + β·style + γ·affect_match
|
| 8 |
-
4. Return the top-ranked response
|
| 9 |
-
|
| 10 |
-
Two entry points:
|
| 11 |
-
run_primary — Qwen3-30B-A3B (or configured primary tier)
|
| 12 |
-
run_fallback — Qwen3-8B (faster, triggered by latency threshold)
|
| 13 |
-
"""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import time
|
| 17 |
|
| 18 |
-
from config.settings import settings
|
| 19 |
-
from generation.llm_client import chat_complete
|
| 20 |
-
from guardrails.checks import check_output
|
| 21 |
-
from pipeline.state import PipelineState
|
|
|
|
| 22 |
|
| 23 |
# ── Persona-specific tone tags (applied on top of affect base tag) ─────────────
|
| 24 |
|
| 25 |
_PERSONA_TONE_OVERRIDES: dict[str, dict[str, str]] = {
|
| 26 |
"mia_chen": {
|
| 27 |
-
"HAPPY":
|
| 28 |
-
"FRUSTRATED":
|
| 29 |
},
|
| 30 |
"gerald_okafor": {
|
| 31 |
-
"HAPPY":
|
| 32 |
-
"FRUSTRATED":
|
| 33 |
},
|
| 34 |
"arjun_mehta": {
|
| 35 |
-
"HAPPY":
|
| 36 |
-
"FRUSTRATED":
|
| 37 |
},
|
| 38 |
}
|
| 39 |
|
|
@@ -46,15 +35,9 @@ def run_fallback(state: PipelineState) -> dict:
|
|
| 46 |
return _run(state, tier="fallback")
|
| 47 |
|
| 48 |
|
| 49 |
-
def route_by_latency(state: PipelineState) -> str:
|
| 50 |
-
"""Conditional edge after retrieval nodes."""
|
| 51 |
-
log = state.get("latency_log") or {}
|
| 52 |
-
elapsed = log.get("t_intent", 0.0) + log.get("t_retrieval", 0.0)
|
| 53 |
-
return "fallback" if elapsed > settings.fallback_latency_threshold else "primary"
|
| 54 |
-
|
| 55 |
-
|
| 56 |
# ── Core implementation ────────────────────────────────────────────────────────
|
| 57 |
|
|
|
|
| 58 |
def _run(state: PipelineState, tier: str) -> dict:
|
| 59 |
t0 = time.perf_counter()
|
| 60 |
|
|
@@ -63,22 +46,37 @@ def _run(state: PipelineState, tier: str) -> dict:
|
|
| 63 |
affect = (state.get("affect") or {}).get("emotion", "NEUTRAL")
|
| 64 |
gen_cfg = state.get("generation_config") or {}
|
| 65 |
chunks = state.get("retrieved_chunks") or []
|
| 66 |
-
history = (state.get("session_history") or [])[-3:]
|
| 67 |
|
| 68 |
-
tone_tag = _resolve_tone_tag(
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
candidates: list[str] = []
|
| 72 |
for _ in range(settings.num_candidates):
|
| 73 |
text = chat_complete(
|
| 74 |
messages=[{"role": "user", "content": prompt}],
|
| 75 |
-
max_tokens=gen_cfg.get("max_tokens", settings.max_tokens_neutral)
|
| 76 |
temperature=0.7,
|
| 77 |
tier=tier,
|
| 78 |
)
|
| 79 |
candidates.append(text)
|
| 80 |
|
| 81 |
-
selected = _rank_candidates(
|
|
|
|
|
|
|
| 82 |
|
| 83 |
# Guardrail — replace with safe fallback if output breaks persona
|
| 84 |
guard = check_output(selected, chunks)
|
|
@@ -117,23 +115,40 @@ def _build_prompt(
|
|
| 117 |
query: str,
|
| 118 |
tone_tag: str,
|
| 119 |
gen_cfg: dict,
|
|
|
|
|
|
|
| 120 |
) -> str:
|
| 121 |
-
memory_block =
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
style_exemplar = profile.get("style_exemplar", "")
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
persona_mod = gen_cfg.get("persona_mod", "baseline")
|
| 126 |
persona_instruction = {
|
| 127 |
-
"amplify_quirks":
|
| 128 |
-
"suppress_humor":
|
| 129 |
-
"baseline":
|
| 130 |
-
"add_confirmation":
|
| 131 |
}.get(persona_mod, "Use your natural communication style.")
|
| 132 |
|
| 133 |
return f"""\
|
| 134 |
-
You are {profile[
|
| 135 |
-
Communication style: {profile[
|
| 136 |
-
{tone_tag}
|
| 137 |
|
| 138 |
Style exemplar — match this register:
|
| 139 |
{style_exemplar}
|
|
@@ -147,7 +162,7 @@ Recent conversation:
|
|
| 147 |
Partner says: {query}
|
| 148 |
|
| 149 |
Instructions:
|
| 150 |
-
- Speak in first person as {profile[
|
| 151 |
- {persona_instruction}
|
| 152 |
- Keep response to 1-3 sentences.
|
| 153 |
- If the answer isn't in your memories, say "I don't know."
|
|
@@ -161,11 +176,8 @@ def _rank_candidates(
|
|
| 161 |
chunks: list[dict],
|
| 162 |
affect: str,
|
| 163 |
profile: dict,
|
|
|
|
| 164 |
) -> str:
|
| 165 |
-
"""
|
| 166 |
-
Composite ranking: score = α·faithful + β·style + γ·affect_match
|
| 167 |
-
Simple heuristic version — replace with NLI + cosine similarity for final eval.
|
| 168 |
-
"""
|
| 169 |
if not candidates:
|
| 170 |
return "I don't know."
|
| 171 |
if len(candidates) == 1:
|
|
@@ -175,21 +187,29 @@ def _rank_candidates(
|
|
| 175 |
style_words = set(profile.get("style", "").lower().split())
|
| 176 |
|
| 177 |
affect_positive_map = {
|
| 178 |
-
"HAPPY":
|
| 179 |
"FRUSTRATED": ["okay", "fine", "sure", "yes", "no"],
|
| 180 |
-
"NEUTRAL":
|
| 181 |
"SURPRISED": ["really", "oh", "interesting", "wow"],
|
| 182 |
}
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
def score(c: str) -> float:
|
| 186 |
words = set(c.lower().split())
|
| 187 |
-
faithful
|
| 188 |
-
style_sim = len(words & style_words)
|
| 189 |
-
affect_m
|
| 190 |
return (
|
| 191 |
settings.rank_alpha * faithful
|
| 192 |
-
+ settings.rank_beta
|
| 193 |
+ settings.rank_gamma * affect_m
|
| 194 |
)
|
| 195 |
|
|
|
|
| 1 |
+
# Planner node — prompt building, candidate generation, composite ranking.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import time
|
| 5 |
|
| 6 |
+
from backend.config.settings import settings
|
| 7 |
+
from backend.generation.llm_client import chat_complete
|
| 8 |
+
from backend.guardrails.checks import check_output
|
| 9 |
+
from backend.pipeline.state import PipelineState
|
| 10 |
+
from backend.sensing.gesture import GESTURE_TO_TAG
|
| 11 |
|
| 12 |
# ── Persona-specific tone tags (applied on top of affect base tag) ─────────────
|
| 13 |
|
| 14 |
_PERSONA_TONE_OVERRIDES: dict[str, dict[str, str]] = {
|
| 15 |
"mia_chen": {
|
| 16 |
+
"HAPPY": "[TONE:WITTY_SARCASTIC]",
|
| 17 |
+
"FRUSTRATED": "[TONE:DIRECT_EMPATHETIC]",
|
| 18 |
},
|
| 19 |
"gerald_okafor": {
|
| 20 |
+
"HAPPY": "[TONE:WARM_FORMAL]",
|
| 21 |
+
"FRUSTRATED": "[TONE:MEASURED_EMPATHETIC]",
|
| 22 |
},
|
| 23 |
"arjun_mehta": {
|
| 24 |
+
"HAPPY": "[TONE:DIRECT_WARM]",
|
| 25 |
+
"FRUSTRATED": "[TONE:MINIMAL_DIRECT]",
|
| 26 |
},
|
| 27 |
}
|
| 28 |
|
|
|
|
| 35 |
return _run(state, tier="fallback")
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
# ── Core implementation ────────────────────────────────────────────────────────
|
| 39 |
|
| 40 |
+
|
| 41 |
def _run(state: PipelineState, tier: str) -> dict:
|
| 42 |
t0 = time.perf_counter()
|
| 43 |
|
|
|
|
| 46 |
affect = (state.get("affect") or {}).get("emotion", "NEUTRAL")
|
| 47 |
gen_cfg = state.get("generation_config") or {}
|
| 48 |
chunks = state.get("retrieved_chunks") or []
|
| 49 |
+
history = (state.get("session_history") or [])[-3:] # last 3 turns only
|
| 50 |
|
| 51 |
+
tone_tag = _resolve_tone_tag(
|
| 52 |
+
user_id, affect, gen_cfg.get("tone_tag", "[TONE:DEFAULT]")
|
| 53 |
+
)
|
| 54 |
+
gesture_tag = state.get("gesture_tag")
|
| 55 |
+
air_written_text = state.get("air_written_text")
|
| 56 |
+
prompt = _build_prompt(
|
| 57 |
+
profile,
|
| 58 |
+
chunks,
|
| 59 |
+
history,
|
| 60 |
+
state["raw_query"],
|
| 61 |
+
tone_tag,
|
| 62 |
+
gen_cfg,
|
| 63 |
+
gesture_tag=gesture_tag,
|
| 64 |
+
air_written_text=air_written_text,
|
| 65 |
+
)
|
| 66 |
|
| 67 |
candidates: list[str] = []
|
| 68 |
for _ in range(settings.num_candidates):
|
| 69 |
text = chat_complete(
|
| 70 |
messages=[{"role": "user", "content": prompt}],
|
| 71 |
+
max_tokens=gen_cfg.get("max_tokens", settings.max_tokens_neutral),
|
| 72 |
temperature=0.7,
|
| 73 |
tier=tier,
|
| 74 |
)
|
| 75 |
candidates.append(text)
|
| 76 |
|
| 77 |
+
selected = _rank_candidates(
|
| 78 |
+
candidates, chunks, affect, profile, gesture_tag=gesture_tag
|
| 79 |
+
)
|
| 80 |
|
| 81 |
# Guardrail — replace with safe fallback if output breaks persona
|
| 82 |
guard = check_output(selected, chunks)
|
|
|
|
| 115 |
query: str,
|
| 116 |
tone_tag: str,
|
| 117 |
gen_cfg: dict,
|
| 118 |
+
gesture_tag: str | None = None,
|
| 119 |
+
air_written_text: str | None = None,
|
| 120 |
) -> str:
|
| 121 |
+
memory_block = (
|
| 122 |
+
"\n".join(f" [{c['bucket']}] {c['text']}" for c in chunks)
|
| 123 |
+
or " (no memories retrieved)"
|
| 124 |
+
)
|
| 125 |
+
history_block = (
|
| 126 |
+
"\n".join(f" {h.get('role', '?')}: {h.get('content', '')}" for h in history)
|
| 127 |
+
or " (start of session)"
|
| 128 |
+
)
|
| 129 |
style_exemplar = profile.get("style_exemplar", "")
|
| 130 |
|
| 131 |
+
gesture_line = ""
|
| 132 |
+
if gesture_tag:
|
| 133 |
+
g_tag = GESTURE_TO_TAG.get(gesture_tag, f"[GESTURE:{gesture_tag}]")
|
| 134 |
+
gesture_line = f"\nActive gesture signal: {g_tag}"
|
| 135 |
+
|
| 136 |
+
air_writing_line = ""
|
| 137 |
+
if air_written_text:
|
| 138 |
+
air_writing_line = f'\nThe user air-wrote: "{air_written_text}" — treat as supplementary intent.'
|
| 139 |
+
|
| 140 |
persona_mod = gen_cfg.get("persona_mod", "baseline")
|
| 141 |
persona_instruction = {
|
| 142 |
+
"amplify_quirks": "Amplify your characteristic style and personality.",
|
| 143 |
+
"suppress_humor": "Be direct and supportive. Suppress humor.",
|
| 144 |
+
"baseline": "Use your natural communication style.",
|
| 145 |
+
"add_confirmation": "Add a clarifying question or confirmation at the end.",
|
| 146 |
}.get(persona_mod, "Use your natural communication style.")
|
| 147 |
|
| 148 |
return f"""\
|
| 149 |
+
You are {profile["name"]}, an AAC device user with {profile["condition"]}.
|
| 150 |
+
Communication style: {profile["style"]}
|
| 151 |
+
{tone_tag}{gesture_line}{air_writing_line}
|
| 152 |
|
| 153 |
Style exemplar — match this register:
|
| 154 |
{style_exemplar}
|
|
|
|
| 162 |
Partner says: {query}
|
| 163 |
|
| 164 |
Instructions:
|
| 165 |
+
- Speak in first person as {profile["name"]}.
|
| 166 |
- {persona_instruction}
|
| 167 |
- Keep response to 1-3 sentences.
|
| 168 |
- If the answer isn't in your memories, say "I don't know."
|
|
|
|
| 176 |
chunks: list[dict],
|
| 177 |
affect: str,
|
| 178 |
profile: dict,
|
| 179 |
+
gesture_tag: str | None = None,
|
| 180 |
) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
if not candidates:
|
| 182 |
return "I don't know."
|
| 183 |
if len(candidates) == 1:
|
|
|
|
| 187 |
style_words = set(profile.get("style", "").lower().split())
|
| 188 |
|
| 189 |
affect_positive_map = {
|
| 190 |
+
"HAPPY": ["great", "love", "enjoy", "happy", "fun"],
|
| 191 |
"FRUSTRATED": ["okay", "fine", "sure", "yes", "no"],
|
| 192 |
+
"NEUTRAL": [],
|
| 193 |
"SURPRISED": ["really", "oh", "interesting", "wow"],
|
| 194 |
}
|
| 195 |
+
gesture_word_map = {
|
| 196 |
+
"THUMBS_UP": ["yes", "good", "agree", "great", "sure"],
|
| 197 |
+
"THUMBS_DOWN": ["no", "disagree", "stop", "don't"],
|
| 198 |
+
"POINTING": ["that", "this", "there", "see"],
|
| 199 |
+
"WAVING": ["hello", "hi", "bye", "goodbye"],
|
| 200 |
+
}
|
| 201 |
+
affect_words = set(affect_positive_map.get(affect, [])) | set(
|
| 202 |
+
gesture_word_map.get(gesture_tag or "", [])
|
| 203 |
+
)
|
| 204 |
|
| 205 |
def score(c: str) -> float:
|
| 206 |
words = set(c.lower().split())
|
| 207 |
+
faithful = len(words & evidence_words) / max(len(words), 1)
|
| 208 |
+
style_sim = len(words & style_words) / max(len(words), 1)
|
| 209 |
+
affect_m = len(words & affect_words) / max(len(words), 1)
|
| 210 |
return (
|
| 211 |
settings.rank_alpha * faithful
|
| 212 |
+
+ settings.rank_beta * style_sim
|
| 213 |
+ settings.rank_gamma * affect_m
|
| 214 |
)
|
| 215 |
|
{pipeline → backend/pipeline}/nodes/retrieval.py
RENAMED
|
@@ -1,27 +1,25 @@
|
|
| 1 |
-
|
| 2 |
-
L3 — Semantic Bucketing & Retrieval node.
|
| 3 |
-
|
| 4 |
-
Two entry points:
|
| 5 |
-
run_fast — FRUSTRATED affect: k=2, single bucket, no reranking
|
| 6 |
-
run_full — standard: k=5, optional bucket hint, BGE cross-encoder reranking
|
| 7 |
-
|
| 8 |
-
Also exports the conditional edge function used by graph.py.
|
| 9 |
-
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import time
|
| 13 |
|
| 14 |
-
from config.settings import settings
|
| 15 |
-
from pipeline.state import PipelineState, RetrievedChunk
|
| 16 |
-
from retrieval.vector_store import retrieve
|
| 17 |
-
from retrieval.bucket_priors import update_priors
|
| 18 |
|
| 19 |
|
| 20 |
def run_fast(state: PipelineState) -> dict:
|
| 21 |
"""Fast retrieval path for FRUSTRATED affect (k=2, no reranker)."""
|
| 22 |
t0 = time.perf_counter()
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
chunks = retrieve(
|
| 26 |
query=state["raw_query"],
|
| 27 |
user_id=state["user_id"],
|
|
@@ -41,9 +39,8 @@ def run_full(state: PipelineState) -> dict:
|
|
| 41 |
# Prefer gaze hint > intent bucket hint > None
|
| 42 |
route = state.get("intent_route") or {}
|
| 43 |
sub_intents = route.get("sub_intents", [])
|
| 44 |
-
bucket_hint = (
|
| 45 |
-
|
| 46 |
-
or next((si.get("bucket_hint") for si in sub_intents if si.get("bucket_hint")), None)
|
| 47 |
)
|
| 48 |
|
| 49 |
chunks = retrieve(
|
|
@@ -58,14 +55,9 @@ def run_full(state: PipelineState) -> dict:
|
|
| 58 |
return _build_return(state, chunks, "full", t0)
|
| 59 |
|
| 60 |
|
| 61 |
-
def route_by_affect(state: PipelineState) -> str:
|
| 62 |
-
"""Conditional edge function — called by graph.py after the intent node."""
|
| 63 |
-
emotion = (state.get("affect") or {}).get("emotion", "NEUTRAL")
|
| 64 |
-
return "fast" if emotion == "FRUSTRATED" else "full"
|
| 65 |
-
|
| 66 |
-
|
| 67 |
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 68 |
|
|
|
|
| 69 |
def _top_prior_bucket(priors: dict[str, float]) -> str | None:
|
| 70 |
if not priors:
|
| 71 |
return None
|
|
|
|
| 1 |
+
# Retrieval node — run_fast (FRUSTRATED) and run_full paths.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import time
|
| 5 |
|
| 6 |
+
from backend.config.settings import settings
|
| 7 |
+
from backend.pipeline.state import PipelineState, RetrievedChunk
|
| 8 |
+
from backend.retrieval.vector_store import retrieve
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
def run_fast(state: PipelineState) -> dict:
|
| 12 |
"""Fast retrieval path for FRUSTRATED affect (k=2, no reranker)."""
|
| 13 |
t0 = time.perf_counter()
|
| 14 |
|
| 15 |
+
priors = state["bucket_priors"]
|
| 16 |
+
prior_vals = list(priors.values()) if priors else []
|
| 17 |
+
priors_uniform = prior_vals and (max(prior_vals) - min(prior_vals)) < 0.05
|
| 18 |
+
bucket_hint = (
|
| 19 |
+
state.get("gaze_bucket")
|
| 20 |
+
if priors_uniform and state.get("gaze_bucket")
|
| 21 |
+
else _top_prior_bucket(priors)
|
| 22 |
+
)
|
| 23 |
chunks = retrieve(
|
| 24 |
query=state["raw_query"],
|
| 25 |
user_id=state["user_id"],
|
|
|
|
| 39 |
# Prefer gaze hint > intent bucket hint > None
|
| 40 |
route = state.get("intent_route") or {}
|
| 41 |
sub_intents = route.get("sub_intents", [])
|
| 42 |
+
bucket_hint = state.get("gaze_bucket") or next(
|
| 43 |
+
(si.get("bucket_hint") for si in sub_intents if si.get("bucket_hint")), None
|
|
|
|
| 44 |
)
|
| 45 |
|
| 46 |
chunks = retrieve(
|
|
|
|
| 55 |
return _build_return(state, chunks, "full", t0)
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 59 |
|
| 60 |
+
|
| 61 |
def _top_prior_bucket(priors: dict[str, float]) -> str | None:
|
| 62 |
if not priors:
|
| 63 |
return None
|
{pipeline → backend/pipeline}/state.py
RENAMED
|
@@ -1,56 +1,54 @@
|
|
| 1 |
-
|
| 2 |
-
Typed state object that flows through every LangGraph node.
|
| 3 |
-
|
| 4 |
-
Each node receives the full PipelineState and returns a dict
|
| 5 |
-
containing only the keys it updates — LangGraph merges them.
|
| 6 |
-
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
from typing import Annotated, Any, Optional
|
| 10 |
-
from typing_extensions import TypedDict
|
| 11 |
import operator
|
|
|
|
| 12 |
|
|
|
|
| 13 |
|
| 14 |
# ── Sub-types ──────────────────────────────────────────────────────────────────
|
| 15 |
|
|
|
|
| 16 |
class AffectVector(TypedDict):
|
| 17 |
-
MAR: float
|
| 18 |
-
EAR: float
|
| 19 |
-
BRI: float
|
| 20 |
-
LCP: float
|
| 21 |
|
| 22 |
|
| 23 |
class AffectState(TypedDict):
|
| 24 |
-
emotion: str
|
| 25 |
vector: AffectVector
|
| 26 |
smoothed: AffectVector # EMA-smoothed vector
|
| 27 |
|
| 28 |
|
| 29 |
class RetrievedChunk(TypedDict):
|
| 30 |
text: str
|
| 31 |
-
bucket: str
|
| 32 |
user: str
|
| 33 |
-
score: float
|
| 34 |
|
| 35 |
|
| 36 |
class SubIntent(TypedDict):
|
| 37 |
-
type: str
|
| 38 |
query: str
|
| 39 |
-
bucket_hint:
|
| 40 |
-
priority: str
|
| 41 |
|
| 42 |
|
| 43 |
class IntentRoute(TypedDict):
|
| 44 |
sub_intents: list[SubIntent]
|
| 45 |
-
style_constraints: dict[str, Any]
|
| 46 |
affect: str
|
| 47 |
|
| 48 |
|
| 49 |
class GenerationConfig(TypedDict):
|
| 50 |
max_tokens: int
|
| 51 |
-
tone_tag: str
|
| 52 |
-
retrieval_mode: str
|
| 53 |
-
persona_mod:
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
class LatencyLog(TypedDict):
|
|
@@ -63,36 +61,37 @@ class LatencyLog(TypedDict):
|
|
| 63 |
|
| 64 |
# ── Main pipeline state ────────────────────────────────────────────────────────
|
| 65 |
|
|
|
|
| 66 |
class PipelineState(TypedDict):
|
| 67 |
# ── Session context (set at turn start, stable across nodes) ──────────────
|
| 68 |
user_id: str
|
| 69 |
-
persona_profile: dict[str, Any]
|
| 70 |
session_history: Annotated[list[dict], operator.add] # auto-appended
|
| 71 |
turn_id: int
|
| 72 |
|
| 73 |
# ── L1: Sensing outputs ───────────────────────────────────────────────────
|
| 74 |
-
affect:
|
| 75 |
-
gesture_tag:
|
| 76 |
-
gaze_bucket:
|
| 77 |
-
air_written_text:
|
| 78 |
|
| 79 |
# ── L2: Intent decomposition outputs ─────────────────────────────────────
|
| 80 |
-
raw_query: str
|
| 81 |
-
intent_route:
|
| 82 |
-
generation_config:
|
| 83 |
|
| 84 |
# ── L3: Retrieval outputs ─────────────────────────────────────────────────
|
| 85 |
retrieved_chunks: list[RetrievedChunk]
|
| 86 |
-
bucket_priors: dict[str, float]
|
| 87 |
-
retrieval_mode_used: str
|
| 88 |
|
| 89 |
# ── L4: Generation outputs ────────────────────────────────────────────────
|
| 90 |
-
augmented_prompt:
|
| 91 |
-
candidates: list[str]
|
| 92 |
-
selected_response:
|
| 93 |
-
llm_tier_used: str
|
| 94 |
|
| 95 |
# ── L5: Feedback / tracking ───────────────────────────────────────────────
|
| 96 |
-
latency_log:
|
| 97 |
-
mlflow_run_id:
|
| 98 |
guardrail_passed: bool
|
|
|
|
| 1 |
+
# Typed state flowing through every LangGraph node.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
|
|
|
| 4 |
import operator
|
| 5 |
+
from typing import Annotated, Any
|
| 6 |
|
| 7 |
+
from typing_extensions import TypedDict
|
| 8 |
|
| 9 |
# ── Sub-types ──────────────────────────────────────────────────────────────────
|
| 10 |
|
| 11 |
+
|
| 12 |
class AffectVector(TypedDict):
|
| 13 |
+
MAR: float # Mouth Aspect Ratio
|
| 14 |
+
EAR: float # Eye Aspect Ratio
|
| 15 |
+
BRI: float # Brow Raise Index
|
| 16 |
+
LCP: float # Lip Corner Pull
|
| 17 |
|
| 18 |
|
| 19 |
class AffectState(TypedDict):
|
| 20 |
+
emotion: str # "HAPPY" | "FRUSTRATED" | "NEUTRAL" | "SURPRISED"
|
| 21 |
vector: AffectVector
|
| 22 |
smoothed: AffectVector # EMA-smoothed vector
|
| 23 |
|
| 24 |
|
| 25 |
class RetrievedChunk(TypedDict):
|
| 26 |
text: str
|
| 27 |
+
bucket: str # family | medical | hobbies | daily_routine | social
|
| 28 |
user: str
|
| 29 |
+
score: float # cross-encoder rerank score
|
| 30 |
|
| 31 |
|
| 32 |
class SubIntent(TypedDict):
|
| 33 |
+
type: str # "PERSONAL" | "CONTEXTUAL" | "OPEN_DOMAIN"
|
| 34 |
query: str
|
| 35 |
+
bucket_hint: str | None
|
| 36 |
+
priority: str # "fast" | "normal"
|
| 37 |
|
| 38 |
|
| 39 |
class IntentRoute(TypedDict):
|
| 40 |
sub_intents: list[SubIntent]
|
| 41 |
+
style_constraints: dict[str, Any] # tone, max_tokens, etc.
|
| 42 |
affect: str
|
| 43 |
|
| 44 |
|
| 45 |
class GenerationConfig(TypedDict):
|
| 46 |
max_tokens: int
|
| 47 |
+
tone_tag: str # e.g. "[TONE:WITTY_SARCASTIC]"
|
| 48 |
+
retrieval_mode: str # "fast" | "full"
|
| 49 |
+
persona_mod: (
|
| 50 |
+
str # "amplify_quirks" | "suppress_humor" | "baseline" | "add_confirmation"
|
| 51 |
+
)
|
| 52 |
|
| 53 |
|
| 54 |
class LatencyLog(TypedDict):
|
|
|
|
| 61 |
|
| 62 |
# ── Main pipeline state ────────────────────────────────────────────────────────
|
| 63 |
|
| 64 |
+
|
| 65 |
class PipelineState(TypedDict):
|
| 66 |
# ── Session context (set at turn start, stable across nodes) ──────────────
|
| 67 |
user_id: str
|
| 68 |
+
persona_profile: dict[str, Any] # full profile from users.json
|
| 69 |
session_history: Annotated[list[dict], operator.add] # auto-appended
|
| 70 |
turn_id: int
|
| 71 |
|
| 72 |
# ── L1: Sensing outputs ───────────────────────────────────────────────────
|
| 73 |
+
affect: AffectState | None
|
| 74 |
+
gesture_tag: str | None # e.g. "THUMBS_UP"
|
| 75 |
+
gaze_bucket: str | None # bucket hinted by gaze fixation
|
| 76 |
+
air_written_text: str | None # concatenated air-written chars
|
| 77 |
|
| 78 |
# ── L2: Intent decomposition outputs ─────────────────────────────────────
|
| 79 |
+
raw_query: str # partner's typed/spoken query
|
| 80 |
+
intent_route: IntentRoute | None # Pydantic-validated routing
|
| 81 |
+
generation_config: GenerationConfig | None
|
| 82 |
|
| 83 |
# ── L3: Retrieval outputs ─────────────────────────────────────────────────
|
| 84 |
retrieved_chunks: list[RetrievedChunk]
|
| 85 |
+
bucket_priors: dict[str, float] # session-level Bayesian priors
|
| 86 |
+
retrieval_mode_used: str # "fast" | "full"
|
| 87 |
|
| 88 |
# ── L4: Generation outputs ────────────────────────────────────────────────
|
| 89 |
+
augmented_prompt: str | None
|
| 90 |
+
candidates: list[str] # 2-3 candidate responses
|
| 91 |
+
selected_response: str | None
|
| 92 |
+
llm_tier_used: str # "primary" | "fallback" | "local"
|
| 93 |
|
| 94 |
# ── L5: Feedback / tracking ───────────────────────────────────────────────
|
| 95 |
+
latency_log: LatencyLog | None
|
| 96 |
+
mlflow_run_id: str | None
|
| 97 |
guardrail_passed: bool
|
{sensing → backend/retrieval}/__init__.py
RENAMED
|
File without changes
|
{retrieval → backend/retrieval}/bucket_priors.py
RENAMED
|
@@ -1,23 +1,10 @@
|
|
| 1 |
-
|
| 2 |
-
Session-level Bayesian bucket priors (proposal §5.4 Bonus).
|
| 3 |
-
|
| 4 |
-
Prior P(bucket_i) is initialized uniformly across the 5 buckets.
|
| 5 |
-
After each accepted response, the prior is updated proportionally
|
| 6 |
-
to the historical acceptance rate for that bucket in the session.
|
| 7 |
-
|
| 8 |
-
P(bucket_i | accept) ∝ P(accept | bucket_i) · P(bucket_i)
|
| 9 |
-
|
| 10 |
-
The updated priors are stored in PipelineState and passed to the
|
| 11 |
-
retrieval node to bias FAISS search toward the most contextually
|
| 12 |
-
likely topic for the session.
|
| 13 |
-
"""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
BUCKETS = ["family", "medical", "hobbies", "daily_routine", "social"]
|
| 17 |
|
| 18 |
|
| 19 |
def uniform_priors() -> dict[str, float]:
|
| 20 |
-
"""Return equal probability mass over all buckets."""
|
| 21 |
p = 1.0 / len(BUCKETS)
|
| 22 |
return {b: p for b in BUCKETS}
|
| 23 |
|
|
@@ -27,14 +14,7 @@ def update_priors(
|
|
| 27 |
accepted_bucket: str,
|
| 28 |
smoothing: float = 0.1,
|
| 29 |
) -> dict[str, float]:
|
| 30 |
-
|
| 31 |
-
Bayesian update: boost the accepted bucket, normalise.
|
| 32 |
-
|
| 33 |
-
Args:
|
| 34 |
-
priors: Current session priors (must sum to ~1.0).
|
| 35 |
-
accepted_bucket: Bucket that sourced the accepted response.
|
| 36 |
-
smoothing: Additive smoothing constant to prevent zero probabilities.
|
| 37 |
-
"""
|
| 38 |
if not priors:
|
| 39 |
priors = uniform_priors()
|
| 40 |
|
|
@@ -46,7 +26,6 @@ def update_priors(
|
|
| 46 |
|
| 47 |
|
| 48 |
def top_bucket(priors: dict[str, float]) -> str:
|
| 49 |
-
"""Return the bucket with the highest prior."""
|
| 50 |
if not priors:
|
| 51 |
return BUCKETS[0]
|
| 52 |
return max(priors, key=priors.get)
|
|
|
|
| 1 |
+
# Session-level Bayesian bucket priors — updated after each accepted turn.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
BUCKETS = ["family", "medical", "hobbies", "daily_routine", "social"]
|
| 5 |
|
| 6 |
|
| 7 |
def uniform_priors() -> dict[str, float]:
|
|
|
|
| 8 |
p = 1.0 / len(BUCKETS)
|
| 9 |
return {b: p for b in BUCKETS}
|
| 10 |
|
|
|
|
| 14 |
accepted_bucket: str,
|
| 15 |
smoothing: float = 0.1,
|
| 16 |
) -> dict[str, float]:
|
| 17 |
+
# Boost accepted bucket, normalise.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
if not priors:
|
| 19 |
priors = uniform_priors()
|
| 20 |
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def top_bucket(priors: dict[str, float]) -> str:
|
|
|
|
| 29 |
if not priors:
|
| 30 |
return BUCKETS[0]
|
| 31 |
return max(priors, key=priors.get)
|
{retrieval → backend/retrieval}/clustering.py
RENAMED
|
@@ -1,42 +1,19 @@
|
|
| 1 |
-
|
| 2 |
-
HDBSCAN-based semantic bucketing over BGE embeddings.
|
| 3 |
-
|
| 4 |
-
Used to validate / discover thematic clusters in persona memories,
|
| 5 |
-
and to auto-assign bucket labels when adding new memory chunks.
|
| 6 |
-
The hand-authored bucket labels in the JSON files remain the ground
|
| 7 |
-
truth — this module provides a data-driven cross-check and supports
|
| 8 |
-
future expansion to unlabelled memory stores.
|
| 9 |
-
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import json
|
| 13 |
-
from pathlib import Path
|
| 14 |
|
| 15 |
import numpy as np
|
| 16 |
|
| 17 |
-
from config.settings import settings
|
| 18 |
-
from retrieval.vector_store import _get_embedder
|
| 19 |
-
|
| 20 |
-
try:
|
| 21 |
-
import hdbscan
|
| 22 |
-
_HDBSCAN_AVAILABLE = True
|
| 23 |
-
except ImportError:
|
| 24 |
-
_HDBSCAN_AVAILABLE = False
|
| 25 |
-
print("[clustering] hdbscan not installed — clustering unavailable.")
|
| 26 |
-
|
| 27 |
|
| 28 |
BUCKET_LABELS = ["family", "medical", "hobbies", "daily_routine", "social"]
|
| 29 |
|
| 30 |
|
| 31 |
def cluster_persona_memories(user_id: str) -> dict[str, list[str]]:
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
Returns a dict mapping cluster_id → list of memory texts.
|
| 36 |
-
Cluster -1 = noise (unclustered points).
|
| 37 |
-
"""
|
| 38 |
-
if not _HDBSCAN_AVAILABLE:
|
| 39 |
-
raise RuntimeError("hdbscan package is required. Run: pip install hdbscan")
|
| 40 |
|
| 41 |
memory_path = settings.memories_dir / f"{user_id}.json"
|
| 42 |
with open(memory_path) as f:
|
|
@@ -59,7 +36,7 @@ def cluster_persona_memories(user_id: str) -> dict[str, list[str]]:
|
|
| 59 |
labels = clusterer.fit_predict(vecs)
|
| 60 |
|
| 61 |
clusters: dict[str, list[str]] = {}
|
| 62 |
-
for text, label,
|
| 63 |
key = f"cluster_{label}" if label >= 0 else "noise"
|
| 64 |
clusters.setdefault(key, []).append(text)
|
| 65 |
|
|
@@ -67,12 +44,8 @@ def cluster_persona_memories(user_id: str) -> dict[str, list[str]]:
|
|
| 67 |
|
| 68 |
|
| 69 |
def evaluate_bucket_alignment(user_id: str) -> dict:
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
Returns per-bucket purity scores (fraction of dominant label in each cluster).
|
| 73 |
-
"""
|
| 74 |
-
if not _HDBSCAN_AVAILABLE:
|
| 75 |
-
raise RuntimeError("hdbscan package is required.")
|
| 76 |
|
| 77 |
memory_path = settings.memories_dir / f"{user_id}.json"
|
| 78 |
with open(memory_path) as f:
|
|
|
|
| 1 |
+
# HDBSCAN-based semantic bucketing over BGE embeddings.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import json
|
|
|
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
|
| 8 |
+
from backend.config.settings import settings
|
| 9 |
+
from backend.retrieval.vector_store import _get_embedder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
BUCKET_LABELS = ["family", "medical", "hobbies", "daily_routine", "social"]
|
| 12 |
|
| 13 |
|
| 14 |
def cluster_persona_memories(user_id: str) -> dict[str, list[str]]:
|
| 15 |
+
# Embed all memory chunks for a persona and cluster with HDBSCAN.
|
| 16 |
+
import hdbscan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
memory_path = settings.memories_dir / f"{user_id}.json"
|
| 19 |
with open(memory_path) as f:
|
|
|
|
| 36 |
labels = clusterer.fit_predict(vecs)
|
| 37 |
|
| 38 |
clusters: dict[str, list[str]] = {}
|
| 39 |
+
for text, label, _true_bucket in zip(texts, labels, true_buckets):
|
| 40 |
key = f"cluster_{label}" if label >= 0 else "noise"
|
| 41 |
clusters.setdefault(key, []).append(text)
|
| 42 |
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def evaluate_bucket_alignment(user_id: str) -> dict:
|
| 47 |
+
# Compare HDBSCAN clusters against hand-authored bucket labels, return purity scores.
|
| 48 |
+
import hdbscan
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
memory_path = settings.memories_dir / f"{user_id}.json"
|
| 51 |
with open(memory_path) as f:
|
{retrieval → backend/retrieval}/vector_store.py
RENAMED
|
@@ -1,44 +1,45 @@
|
|
| 1 |
-
|
| 2 |
-
FAISS-backed dense retrieval with BGE embeddings and cross-encoder reranking.
|
| 3 |
-
|
| 4 |
-
Models are lazy-loaded on first use (safe for FastAPI / LangGraph workers).
|
| 5 |
-
|
| 6 |
-
NOTE: The FAISS indexes in data/faiss_store/ must be built with BGE embeddings.
|
| 7 |
-
Run `python -m retrieval.vector_store` to rebuild all persona indexes.
|
| 8 |
-
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import json
|
| 12 |
-
import time
|
| 13 |
from functools import lru_cache
|
| 14 |
from pathlib import Path
|
| 15 |
|
| 16 |
-
import faiss
|
| 17 |
import numpy as np
|
| 18 |
-
from sentence_transformers import CrossEncoder, SentenceTransformer
|
| 19 |
|
| 20 |
-
from config.settings import settings
|
| 21 |
-
from pipeline.state import RetrievedChunk
|
| 22 |
|
| 23 |
-
# ── Lazy model singletons ──────────────────────────────────────────────────────
|
| 24 |
|
| 25 |
@lru_cache(maxsize=1)
|
| 26 |
-
def _get_embedder()
|
|
|
|
|
|
|
| 27 |
return SentenceTransformer(settings.embed_model)
|
| 28 |
|
| 29 |
|
| 30 |
@lru_cache(maxsize=1)
|
| 31 |
-
def _get_reranker()
|
|
|
|
|
|
|
| 32 |
return CrossEncoder(settings.rerank_model)
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
# ── Index cache (one FAISS index per user_id) ─────────────────────────────────
|
| 36 |
|
| 37 |
-
_index_cache: dict[str, tuple
|
| 38 |
|
| 39 |
|
| 40 |
-
def load_index(user_id: str)
|
| 41 |
if user_id not in _index_cache:
|
|
|
|
| 42 |
store_path = settings.faiss_store_dir / user_id
|
| 43 |
index = faiss.read_index(str(store_path / "index.faiss"))
|
| 44 |
with open(store_path / "meta.json") as f:
|
|
@@ -49,6 +50,7 @@ def load_index(user_id: str) -> tuple[faiss.Index, list[dict]]:
|
|
| 49 |
|
| 50 |
# ── Core retrieve function ─────────────────────────────────────────────────────
|
| 51 |
|
|
|
|
| 52 |
def retrieve(
|
| 53 |
query: str,
|
| 54 |
user_id: str,
|
|
@@ -56,67 +58,45 @@ def retrieve(
|
|
| 56 |
rerank_k: int = 3,
|
| 57 |
bucket_filter: str | None = None,
|
| 58 |
use_reranker: bool = True,
|
| 59 |
-
debug: bool = False,
|
| 60 |
) -> list[RetrievedChunk]:
|
| 61 |
-
"""
|
| 62 |
-
Two-stage retrieval:
|
| 63 |
-
1. BGE-small-en-v1.5 bi-encoder → FAISS IndexFlatIP (cosine similarity)
|
| 64 |
-
2. BGE-reranker-v2-m3 cross-encoder reranking (multilingual, skippable)
|
| 65 |
-
|
| 66 |
-
Args:
|
| 67 |
-
query: Partner's text query.
|
| 68 |
-
user_id: Persona identifier (e.g. "mia_chen").
|
| 69 |
-
top_k: Number of candidates from FAISS before reranking.
|
| 70 |
-
rerank_k: Final number of chunks returned after reranking.
|
| 71 |
-
bucket_filter: If set, restrict candidates to this memory bucket.
|
| 72 |
-
use_reranker: False for the FRUSTRATED fast path.
|
| 73 |
-
debug: Return timing breakdown alongside results.
|
| 74 |
-
"""
|
| 75 |
embedder = _get_embedder()
|
| 76 |
index, meta = load_index(user_id)
|
| 77 |
|
| 78 |
-
|
| 79 |
-
q_vec = embedder.encode(
|
| 80 |
-
[query], convert_to_numpy=True, normalize_embeddings=True
|
| 81 |
-
)
|
| 82 |
-
t_embed = time.perf_counter() - t0
|
| 83 |
-
|
| 84 |
-
t0 = time.perf_counter()
|
| 85 |
_, idxs = index.search(q_vec, top_k)
|
| 86 |
-
t_faiss = time.perf_counter() - t0
|
| 87 |
|
| 88 |
-
candidates = [meta[i] for i in idxs[0] if i < len(meta)]
|
| 89 |
|
| 90 |
if bucket_filter:
|
| 91 |
filtered = [c for c in candidates if c["bucket"] == bucket_filter]
|
| 92 |
-
candidates = filtered if filtered else candidates
|
| 93 |
|
| 94 |
-
t0 = time.perf_counter()
|
| 95 |
if use_reranker and len(candidates) > 1:
|
| 96 |
reranker = _get_reranker()
|
| 97 |
pairs = [(query, c["text"]) for c in candidates]
|
| 98 |
ce_scores = reranker.predict(pairs)
|
| 99 |
ranked = sorted(zip(ce_scores, candidates), key=lambda x: x[0], reverse=True)
|
| 100 |
top = [
|
| 101 |
-
RetrievedChunk(
|
|
|
|
|
|
|
| 102 |
for s, c in ranked[:rerank_k]
|
| 103 |
]
|
| 104 |
else:
|
| 105 |
top = [
|
| 106 |
-
RetrievedChunk(
|
|
|
|
|
|
|
| 107 |
for c in candidates[:rerank_k]
|
| 108 |
]
|
| 109 |
-
t_rerank = time.perf_counter() - t0
|
| 110 |
|
| 111 |
-
if debug:
|
| 112 |
-
return top, {"t_embed": t_embed, "t_faiss": t_faiss, "t_rerank": t_rerank}
|
| 113 |
return top
|
| 114 |
|
| 115 |
|
| 116 |
# ── Index builder ──────────────────────────────────────────────────────────────
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
with open(persona_path) as f:
|
| 121 |
persona = json.load(f)
|
| 122 |
|
|
@@ -132,15 +112,16 @@ def build_index(persona_path: str | Path) -> tuple[faiss.Index, list[dict]]:
|
|
| 132 |
vecs = embedder.encode(chunks, convert_to_numpy=True, normalize_embeddings=True)
|
| 133 |
|
| 134 |
dim = vecs.shape[1]
|
|
|
|
| 135 |
index = faiss.IndexFlatIP(dim)
|
| 136 |
index.add(vecs.astype(np.float32))
|
| 137 |
return index, meta
|
| 138 |
|
| 139 |
|
| 140 |
-
def save_index(index
|
| 141 |
p = Path(save_dir)
|
| 142 |
p.mkdir(parents=True, exist_ok=True)
|
| 143 |
-
|
| 144 |
with open(p / "meta.json", "w") as f:
|
| 145 |
json.dump(meta, f, indent=2)
|
| 146 |
|
|
@@ -149,7 +130,6 @@ def build_all(
|
|
| 149 |
memories_dir: str | Path | None = None,
|
| 150 |
store_dir: str | Path | None = None,
|
| 151 |
) -> None:
|
| 152 |
-
"""Rebuild FAISS indexes for all personas using the configured BGE embedder."""
|
| 153 |
memories_dir = Path(memories_dir or settings.memories_dir)
|
| 154 |
store_dir = Path(store_dir or settings.faiss_store_dir)
|
| 155 |
|
|
|
|
| 1 |
+
# FAISS retrieval with BGE embeddings and cross-encoder reranking.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import json
|
|
|
|
| 5 |
from functools import lru_cache
|
| 6 |
from pathlib import Path
|
| 7 |
|
|
|
|
| 8 |
import numpy as np
|
|
|
|
| 9 |
|
| 10 |
+
from backend.config.settings import settings
|
| 11 |
+
from backend.pipeline.state import RetrievedChunk
|
| 12 |
|
|
|
|
| 13 |
|
| 14 |
@lru_cache(maxsize=1)
|
| 15 |
+
def _get_embedder():
|
| 16 |
+
from sentence_transformers import SentenceTransformer
|
| 17 |
+
|
| 18 |
return SentenceTransformer(settings.embed_model)
|
| 19 |
|
| 20 |
|
| 21 |
@lru_cache(maxsize=1)
|
| 22 |
+
def _get_reranker():
|
| 23 |
+
from sentence_transformers import CrossEncoder
|
| 24 |
+
|
| 25 |
return CrossEncoder(settings.rerank_model)
|
| 26 |
|
| 27 |
|
| 28 |
+
@lru_cache(maxsize=1)
|
| 29 |
+
def _get_faiss():
|
| 30 |
+
import faiss
|
| 31 |
+
|
| 32 |
+
return faiss
|
| 33 |
+
|
| 34 |
+
|
| 35 |
# ── Index cache (one FAISS index per user_id) ─────────────────────────────────
|
| 36 |
|
| 37 |
+
_index_cache: dict[str, tuple] = {}
|
| 38 |
|
| 39 |
|
| 40 |
+
def load_index(user_id: str):
|
| 41 |
if user_id not in _index_cache:
|
| 42 |
+
faiss = _get_faiss()
|
| 43 |
store_path = settings.faiss_store_dir / user_id
|
| 44 |
index = faiss.read_index(str(store_path / "index.faiss"))
|
| 45 |
with open(store_path / "meta.json") as f:
|
|
|
|
| 50 |
|
| 51 |
# ── Core retrieve function ─────────────────────────────────────────────────────
|
| 52 |
|
| 53 |
+
|
| 54 |
def retrieve(
|
| 55 |
query: str,
|
| 56 |
user_id: str,
|
|
|
|
| 58 |
rerank_k: int = 3,
|
| 59 |
bucket_filter: str | None = None,
|
| 60 |
use_reranker: bool = True,
|
|
|
|
| 61 |
) -> list[RetrievedChunk]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
embedder = _get_embedder()
|
| 63 |
index, meta = load_index(user_id)
|
| 64 |
|
| 65 |
+
q_vec = embedder.encode([query], convert_to_numpy=True, normalize_embeddings=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
_, idxs = index.search(q_vec, top_k)
|
|
|
|
| 67 |
|
| 68 |
+
candidates = [meta[i] for i in idxs[0] if 0 <= i < len(meta)]
|
| 69 |
|
| 70 |
if bucket_filter:
|
| 71 |
filtered = [c for c in candidates if c["bucket"] == bucket_filter]
|
| 72 |
+
candidates = filtered if filtered else candidates # fallback: all buckets
|
| 73 |
|
|
|
|
| 74 |
if use_reranker and len(candidates) > 1:
|
| 75 |
reranker = _get_reranker()
|
| 76 |
pairs = [(query, c["text"]) for c in candidates]
|
| 77 |
ce_scores = reranker.predict(pairs)
|
| 78 |
ranked = sorted(zip(ce_scores, candidates), key=lambda x: x[0], reverse=True)
|
| 79 |
top = [
|
| 80 |
+
RetrievedChunk(
|
| 81 |
+
text=c["text"], bucket=c["bucket"], user=c["user"], score=float(s)
|
| 82 |
+
)
|
| 83 |
for s, c in ranked[:rerank_k]
|
| 84 |
]
|
| 85 |
else:
|
| 86 |
top = [
|
| 87 |
+
RetrievedChunk(
|
| 88 |
+
text=c["text"], bucket=c["bucket"], user=c["user"], score=1.0
|
| 89 |
+
)
|
| 90 |
for c in candidates[:rerank_k]
|
| 91 |
]
|
|
|
|
| 92 |
|
|
|
|
|
|
|
| 93 |
return top
|
| 94 |
|
| 95 |
|
| 96 |
# ── Index builder ──────────────────────────────────────────────────────────────
|
| 97 |
|
| 98 |
+
|
| 99 |
+
def build_index(persona_path: str | Path):
|
| 100 |
with open(persona_path) as f:
|
| 101 |
persona = json.load(f)
|
| 102 |
|
|
|
|
| 112 |
vecs = embedder.encode(chunks, convert_to_numpy=True, normalize_embeddings=True)
|
| 113 |
|
| 114 |
dim = vecs.shape[1]
|
| 115 |
+
faiss = _get_faiss()
|
| 116 |
index = faiss.IndexFlatIP(dim)
|
| 117 |
index.add(vecs.astype(np.float32))
|
| 118 |
return index, meta
|
| 119 |
|
| 120 |
|
| 121 |
+
def save_index(index, meta: list[dict], save_dir: str | Path) -> None:
|
| 122 |
p = Path(save_dir)
|
| 123 |
p.mkdir(parents=True, exist_ok=True)
|
| 124 |
+
_get_faiss().write_index(index, str(p / "index.faiss"))
|
| 125 |
with open(p / "meta.json", "w") as f:
|
| 126 |
json.dump(meta, f, indent=2)
|
| 127 |
|
|
|
|
| 130 |
memories_dir: str | Path | None = None,
|
| 131 |
store_dir: str | Path | None = None,
|
| 132 |
) -> None:
|
|
|
|
| 133 |
memories_dir = Path(memories_dir or settings.memories_dir)
|
| 134 |
store_dir = Path(store_dir or settings.faiss_store_dir)
|
| 135 |
|
backend/sensing/__init__.py
ADDED
|
File without changes
|
{sensing → backend/sensing}/air_writing.py
RENAMED
|
@@ -1,35 +1,14 @@
|
|
| 1 |
-
|
| 2 |
-
L1 — Air writing recognition via index-finger tip trajectory (proposal §5.2).
|
| 3 |
-
|
| 4 |
-
Tracks MediaPipe Hands landmark 8 (index fingertip) across frames.
|
| 5 |
-
Stroke segmentation uses velocity thresholding:
|
| 6 |
-
- stroke starts when velocity > START_VEL px/frame
|
| 7 |
-
- stroke ends when velocity < END_VEL px/frame for > GAP_MS ms
|
| 8 |
-
|
| 9 |
-
Segmented strokes are classified against a template library using
|
| 10 |
-
Dynamic Time Warping (DTW). Supports:
|
| 11 |
-
- 26 uppercase English letters (A-Z)
|
| 12 |
-
- 10 digits (0-9)
|
| 13 |
-
- 10 most frequent Devanagari characters (for Arjun's Hindi inputs)
|
| 14 |
-
|
| 15 |
-
Recognised characters are concatenated and returned as a text string
|
| 16 |
-
to the intent decomposition layer.
|
| 17 |
-
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
import time
|
| 21 |
-
from collections import deque
|
| 22 |
from dataclasses import dataclass, field
|
| 23 |
|
| 24 |
import numpy as np
|
| 25 |
|
| 26 |
-
from config.settings import settings
|
| 27 |
|
| 28 |
-
|
| 29 |
-
import mediapipe as mp
|
| 30 |
-
_MP_AVAILABLE = True
|
| 31 |
-
except ImportError:
|
| 32 |
-
_MP_AVAILABLE = False
|
| 33 |
|
| 34 |
# ── Landmark index ─────────────────────────────────────────────────────────────
|
| 35 |
_INDEX_TIP = 8
|
|
@@ -41,6 +20,7 @@ class AirWriter:
|
|
| 41 |
Stateful air-writing recogniser. Feed frames from a webcam loop.
|
| 42 |
Call `get_text()` to retrieve and clear the current buffer.
|
| 43 |
"""
|
|
|
|
| 44 |
_trajectory: list[tuple[float, float]] = field(default_factory=list)
|
| 45 |
_in_stroke: bool = False
|
| 46 |
_stroke_end_time: float = field(default=0.0)
|
|
@@ -48,8 +28,9 @@ class AirWriter:
|
|
| 48 |
_templates: dict[str, np.ndarray] = field(default_factory=dict)
|
| 49 |
|
| 50 |
def __post_init__(self):
|
| 51 |
-
|
| 52 |
-
|
|
|
|
| 53 |
self._hands = mp.solutions.hands.Hands(
|
| 54 |
static_image_mode=False,
|
| 55 |
max_num_hands=1,
|
|
@@ -65,6 +46,7 @@ class AirWriter:
|
|
| 65 |
completes, or None otherwise.
|
| 66 |
"""
|
| 67 |
import cv2
|
|
|
|
| 68 |
rgb = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB)
|
| 69 |
result = self._hands.process(rgb)
|
| 70 |
|
|
@@ -82,7 +64,7 @@ class AirWriter:
|
|
| 82 |
self._prev_pt = tip
|
| 83 |
|
| 84 |
start_v = settings.air_write_velocity_start
|
| 85 |
-
end_v
|
| 86 |
|
| 87 |
if velocity > start_v:
|
| 88 |
self._in_stroke = True
|
|
@@ -133,6 +115,7 @@ class AirWriter:
|
|
| 133 |
|
| 134 |
# ── DTW helpers ───────────────────────────────────────────────────────────────
|
| 135 |
|
|
|
|
| 136 |
def _normalise_trajectory(pts: np.ndarray) -> np.ndarray:
|
| 137 |
"""Scale trajectory to unit bounding box, resample to 32 points."""
|
| 138 |
pts = pts - pts.min(axis=0)
|
|
@@ -141,10 +124,12 @@ def _normalise_trajectory(pts: np.ndarray) -> np.ndarray:
|
|
| 141 |
# Resample to fixed length via linear interpolation
|
| 142 |
t_old = np.linspace(0, 1, len(pts))
|
| 143 |
t_new = np.linspace(0, 1, 32)
|
| 144 |
-
return np.column_stack(
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
def _dtw_distance(a: np.ndarray, b: np.ndarray) -> float:
|
|
@@ -160,17 +145,11 @@ def _dtw_distance(a: np.ndarray, b: np.ndarray) -> float:
|
|
| 160 |
|
| 161 |
|
| 162 |
def _load_templates() -> dict[str, np.ndarray]:
|
| 163 |
-
""
|
| 164 |
-
Load pre-recorded stroke templates from disk.
|
| 165 |
-
Template files should be numpy arrays of shape (32, 2) stored as .npy.
|
| 166 |
-
Returns an empty dict if no template directory exists yet.
|
| 167 |
-
"""
|
| 168 |
-
from pathlib import Path
|
| 169 |
-
template_dir = Path("data/air_write_templates")
|
| 170 |
if not template_dir.exists():
|
| 171 |
return {}
|
| 172 |
templates = {}
|
| 173 |
for f in template_dir.glob("*.npy"):
|
| 174 |
-
char = f.stem
|
| 175 |
templates[char] = np.load(f)
|
| 176 |
return templates
|
|
|
|
| 1 |
+
# Air writing recognition — fingertip trajectory → DTW character matching.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import time
|
|
|
|
| 5 |
from dataclasses import dataclass, field
|
| 6 |
|
| 7 |
import numpy as np
|
| 8 |
|
| 9 |
+
from backend.config.settings import settings
|
| 10 |
|
| 11 |
+
mp = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
# ── Landmark index ─────────────────────────────────────────────────────────────
|
| 14 |
_INDEX_TIP = 8
|
|
|
|
| 20 |
Stateful air-writing recogniser. Feed frames from a webcam loop.
|
| 21 |
Call `get_text()` to retrieve and clear the current buffer.
|
| 22 |
"""
|
| 23 |
+
|
| 24 |
_trajectory: list[tuple[float, float]] = field(default_factory=list)
|
| 25 |
_in_stroke: bool = False
|
| 26 |
_stroke_end_time: float = field(default=0.0)
|
|
|
|
| 28 |
_templates: dict[str, np.ndarray] = field(default_factory=dict)
|
| 29 |
|
| 30 |
def __post_init__(self):
|
| 31 |
+
global mp
|
| 32 |
+
import mediapipe as mp
|
| 33 |
+
|
| 34 |
self._hands = mp.solutions.hands.Hands(
|
| 35 |
static_image_mode=False,
|
| 36 |
max_num_hands=1,
|
|
|
|
| 46 |
completes, or None otherwise.
|
| 47 |
"""
|
| 48 |
import cv2
|
| 49 |
+
|
| 50 |
rgb = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB)
|
| 51 |
result = self._hands.process(rgb)
|
| 52 |
|
|
|
|
| 64 |
self._prev_pt = tip
|
| 65 |
|
| 66 |
start_v = settings.air_write_velocity_start
|
| 67 |
+
end_v = settings.air_write_velocity_end
|
| 68 |
|
| 69 |
if velocity > start_v:
|
| 70 |
self._in_stroke = True
|
|
|
|
| 115 |
|
| 116 |
# ── DTW helpers ───────────────────────────────────────────────────────────────
|
| 117 |
|
| 118 |
+
|
| 119 |
def _normalise_trajectory(pts: np.ndarray) -> np.ndarray:
|
| 120 |
"""Scale trajectory to unit bounding box, resample to 32 points."""
|
| 121 |
pts = pts - pts.min(axis=0)
|
|
|
|
| 124 |
# Resample to fixed length via linear interpolation
|
| 125 |
t_old = np.linspace(0, 1, len(pts))
|
| 126 |
t_new = np.linspace(0, 1, 32)
|
| 127 |
+
return np.column_stack(
|
| 128 |
+
[
|
| 129 |
+
np.interp(t_new, t_old, pts[:, 0]),
|
| 130 |
+
np.interp(t_new, t_old, pts[:, 1]),
|
| 131 |
+
]
|
| 132 |
+
)
|
| 133 |
|
| 134 |
|
| 135 |
def _dtw_distance(a: np.ndarray, b: np.ndarray) -> float:
|
|
|
|
| 145 |
|
| 146 |
|
| 147 |
def _load_templates() -> dict[str, np.ndarray]:
|
| 148 |
+
template_dir = settings.data_dir / "air_write_templates"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
if not template_dir.exists():
|
| 150 |
return {}
|
| 151 |
templates = {}
|
| 152 |
for f in template_dir.glob("*.npy"):
|
| 153 |
+
char = f.stem # filename = character label
|
| 154 |
templates[char] = np.load(f)
|
| 155 |
return templates
|
{sensing → backend/sensing}/face_mesh.py
RENAMED
|
@@ -1,61 +1,37 @@
|
|
| 1 |
-
|
| 2 |
-
L1 — Facial affect detection via MediaPipe 2D Face Mesh.
|
| 3 |
-
|
| 4 |
-
Extracts 4 geometric features from 478 landmarks at ~10 fps:
|
| 5 |
-
MAR — Mouth Aspect Ratio (surprise / speech attempt)
|
| 6 |
-
EAR — Eye Aspect Ratio (frustration / blink)
|
| 7 |
-
BRI — Brow Raise Index (surprise / questioning)
|
| 8 |
-
LCP — Lip Corner Pull (smile vs frown)
|
| 9 |
-
|
| 10 |
-
These form the affect vector fed into MobileNetV3-Small affect classifier,
|
| 11 |
-
which maps to one of 4 actionable states: HAPPY | FRUSTRATED | NEUTRAL | SURPRISED.
|
| 12 |
-
|
| 13 |
-
EMA smoothing (α=0.3) prevents transient expressions (sneezes, blinks)
|
| 14 |
-
from destabilising the detected state across turns.
|
| 15 |
-
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
-
import time
|
| 19 |
from dataclasses import dataclass, field
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
|
| 23 |
-
from config.settings import settings
|
| 24 |
-
from pipeline.state import AffectState, AffectVector
|
| 25 |
-
|
| 26 |
-
try:
|
| 27 |
-
import mediapipe as mp
|
| 28 |
-
_MP_AVAILABLE = True
|
| 29 |
-
except ImportError:
|
| 30 |
-
_MP_AVAILABLE = False
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
_CV2_AVAILABLE = True
|
| 35 |
-
except ImportError:
|
| 36 |
-
_CV2_AVAILABLE = False
|
| 37 |
|
| 38 |
|
| 39 |
-
# ── MediaPipe landmark indices
|
| 40 |
|
| 41 |
# MAR — mouth vertical / horizontal ratio
|
| 42 |
-
_MOUTH_TOP
|
| 43 |
_MOUTH_BOTTOM = 14
|
| 44 |
-
_MOUTH_LEFT
|
| 45 |
-
_MOUTH_RIGHT
|
| 46 |
|
| 47 |
# EAR — eye vertical / horizontal ratio (right eye)
|
| 48 |
-
_EYE_TOP
|
| 49 |
_EYE_BOTTOM = 145
|
| 50 |
-
_EYE_LEFT
|
| 51 |
-
_EYE_RIGHT
|
| 52 |
|
| 53 |
# BRI — brow vertical displacement relative to eye centre
|
| 54 |
-
_BROW_LEFT
|
| 55 |
_BROW_RIGHT = 300
|
| 56 |
|
| 57 |
# LCP — mouth corner horizontal displacement from neutral baseline
|
| 58 |
-
_CORNER_LEFT
|
| 59 |
_CORNER_RIGHT = 291
|
| 60 |
|
| 61 |
|
|
@@ -70,20 +46,22 @@ class AffectDetector:
|
|
| 70 |
Stateful detector that maintains EMA-smoothed affect across frames.
|
| 71 |
Create one instance per session and call `process_frame` each frame.
|
| 72 |
"""
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
| 75 |
_calibrated: bool = False
|
| 76 |
|
| 77 |
def __post_init__(self):
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
raise ImportError("opencv-python is required: pip install opencv-python")
|
| 82 |
|
| 83 |
self._face_mesh = mp.solutions.face_mesh.FaceMesh(
|
| 84 |
static_image_mode=False,
|
| 85 |
max_num_faces=1,
|
| 86 |
-
refine_landmarks=True,
|
| 87 |
min_detection_confidence=0.5,
|
| 88 |
min_tracking_confidence=0.5,
|
| 89 |
)
|
|
@@ -146,14 +124,15 @@ class AffectDetector:
|
|
| 146 |
# LCP — average horizontal mouth corner displacement
|
| 147 |
LCP = float((pt(_CORNER_LEFT)[0] + pt(_CORNER_RIGHT)[0]) / 2)
|
| 148 |
|
| 149 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
@staticmethod
|
| 152 |
def _classify(v: AffectVector) -> str:
|
| 153 |
-
"""
|
| 154 |
-
Rule-based classifier over the 4 geometric features.
|
| 155 |
-
Replace with MobileNetV3-Small for final evaluation.
|
| 156 |
-
"""
|
| 157 |
if v["BRI"] > 0.25 and v["MAR"] > 0.3:
|
| 158 |
return "SURPRISED"
|
| 159 |
if v["EAR"] < 0.15 and v["LCP"] < -5:
|
|
|
|
| 1 |
+
# Facial affect detection via MediaPipe Face Mesh (MAR/EAR/BRI/LCP → emotion).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
|
| 8 |
+
from backend.config.settings import settings
|
| 9 |
+
from backend.pipeline.state import AffectState, AffectVector
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
mp = None
|
| 12 |
+
cv2 = None
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
+
# ── MediaPipe landmark indices ────────────────────────────────────────────────
|
| 16 |
|
| 17 |
# MAR — mouth vertical / horizontal ratio
|
| 18 |
+
_MOUTH_TOP = 13
|
| 19 |
_MOUTH_BOTTOM = 14
|
| 20 |
+
_MOUTH_LEFT = 61
|
| 21 |
+
_MOUTH_RIGHT = 291
|
| 22 |
|
| 23 |
# EAR — eye vertical / horizontal ratio (right eye)
|
| 24 |
+
_EYE_TOP = 159
|
| 25 |
_EYE_BOTTOM = 145
|
| 26 |
+
_EYE_LEFT = 33
|
| 27 |
+
_EYE_RIGHT = 133
|
| 28 |
|
| 29 |
# BRI — brow vertical displacement relative to eye centre
|
| 30 |
+
_BROW_LEFT = 70
|
| 31 |
_BROW_RIGHT = 300
|
| 32 |
|
| 33 |
# LCP — mouth corner horizontal displacement from neutral baseline
|
| 34 |
+
_CORNER_LEFT = 61
|
| 35 |
_CORNER_RIGHT = 291
|
| 36 |
|
| 37 |
|
|
|
|
| 46 |
Stateful detector that maintains EMA-smoothed affect across frames.
|
| 47 |
Create one instance per session and call `process_frame` each frame.
|
| 48 |
"""
|
| 49 |
+
|
| 50 |
+
_smoothed: AffectVector = field(
|
| 51 |
+
default_factory=lambda: AffectVector(MAR=0.0, EAR=0.3, BRI=0.0, LCP=0.0)
|
| 52 |
+
)
|
| 53 |
+
_neutral_lcp: float = 0.0 # calibrated at session start
|
| 54 |
_calibrated: bool = False
|
| 55 |
|
| 56 |
def __post_init__(self):
|
| 57 |
+
global mp, cv2
|
| 58 |
+
import cv2
|
| 59 |
+
import mediapipe as mp
|
|
|
|
| 60 |
|
| 61 |
self._face_mesh = mp.solutions.face_mesh.FaceMesh(
|
| 62 |
static_image_mode=False,
|
| 63 |
max_num_faces=1,
|
| 64 |
+
refine_landmarks=True, # enables iris landmarks (468-477)
|
| 65 |
min_detection_confidence=0.5,
|
| 66 |
min_tracking_confidence=0.5,
|
| 67 |
)
|
|
|
|
| 124 |
# LCP — average horizontal mouth corner displacement
|
| 125 |
LCP = float((pt(_CORNER_LEFT)[0] + pt(_CORNER_RIGHT)[0]) / 2)
|
| 126 |
|
| 127 |
+
return {
|
| 128 |
+
"MAR": float(MAR),
|
| 129 |
+
"EAR": float(EAR),
|
| 130 |
+
"BRI": float(BRI),
|
| 131 |
+
"LCP": float(LCP),
|
| 132 |
+
}
|
| 133 |
|
| 134 |
@staticmethod
|
| 135 |
def _classify(v: AffectVector) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
if v["BRI"] > 0.25 and v["MAR"] > 0.3:
|
| 137 |
return "SURPRISED"
|
| 138 |
if v["EAR"] < 0.15 and v["LCP"] < -5:
|
{sensing → backend/sensing}/gaze.py
RENAMED
|
@@ -1,47 +1,27 @@
|
|
| 1 |
-
|
| 2 |
-
L1 — Gaze-based retrieval activation (Bonus feature, proposal §5.2).
|
| 3 |
-
|
| 4 |
-
Uses MediaPipe iris landmarks (468-472) to estimate gaze direction as
|
| 5 |
-
a 2D screen-coordinate vector. Sustained fixation (> 1.5 s dwell time)
|
| 6 |
-
on a defined UI region pre-biases the retrieval layer toward the
|
| 7 |
-
corresponding memory bucket.
|
| 8 |
-
|
| 9 |
-
UI region → bucket mapping:
|
| 10 |
-
top-left quadrant → family
|
| 11 |
-
top-right quadrant → medical
|
| 12 |
-
bottom-left quadrant → hobbies
|
| 13 |
-
bottom-right quadrant → daily_routine
|
| 14 |
-
centre strip → social
|
| 15 |
-
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import time
|
| 19 |
from dataclasses import dataclass, field
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
try:
|
| 26 |
-
import mediapipe as mp
|
| 27 |
-
_MP_AVAILABLE = True
|
| 28 |
-
except ImportError:
|
| 29 |
-
_MP_AVAILABLE = False
|
| 30 |
|
| 31 |
|
| 32 |
# ── Iris landmark indices ──────────────────────────────────────────────────────
|
| 33 |
# MediaPipe refine_landmarks=True adds iris landmarks 468-477
|
| 34 |
-
_LEFT_IRIS_CENTER
|
| 35 |
_RIGHT_IRIS_CENTER = 473
|
| 36 |
|
| 37 |
# ── Screen region → bucket map ─────────────────────────────────────────────────
|
| 38 |
# Defined as (x_min, y_min, x_max, y_max) in normalised [0,1] coords
|
| 39 |
_REGION_BUCKET: list[tuple[tuple[float, float, float, float], str]] = [
|
|
|
|
| 40 |
((0.0, 0.0, 0.5, 0.5), "family"),
|
| 41 |
((0.5, 0.0, 1.0, 0.5), "medical"),
|
| 42 |
((0.0, 0.5, 0.5, 1.0), "hobbies"),
|
| 43 |
((0.5, 0.5, 1.0, 1.0), "daily_routine"),
|
| 44 |
-
((0.3, 0.3, 0.7, 0.7), "social"), # centre strip (checked last → lowest priority)
|
| 45 |
]
|
| 46 |
|
| 47 |
|
|
@@ -51,12 +31,14 @@ class GazeTracker:
|
|
| 51 |
Stateful gaze tracker. Call `process_frame` each frame.
|
| 52 |
Returns the bucket name when dwell threshold is exceeded, else None.
|
| 53 |
"""
|
|
|
|
| 54 |
_dwell_start: float = field(default=0.0)
|
| 55 |
_current_region: str | None = field(default=None)
|
| 56 |
|
| 57 |
def __post_init__(self):
|
| 58 |
-
|
| 59 |
-
|
|
|
|
| 60 |
self._face_mesh = mp.solutions.face_mesh.FaceMesh(
|
| 61 |
static_image_mode=False,
|
| 62 |
max_num_faces=1,
|
|
@@ -66,11 +48,8 @@ class GazeTracker:
|
|
| 66 |
)
|
| 67 |
|
| 68 |
def process_frame(self, bgr_frame) -> str | None:
|
| 69 |
-
"""
|
| 70 |
-
Returns the hinted bucket name once dwell threshold is exceeded,
|
| 71 |
-
then resets the dwell timer. Returns None otherwise.
|
| 72 |
-
"""
|
| 73 |
import cv2
|
|
|
|
| 74 |
rgb = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB)
|
| 75 |
result = self._face_mesh.process(rgb)
|
| 76 |
|
|
|
|
| 1 |
+
# Gaze-based retrieval bucket hinting via MediaPipe iris landmarks.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import time
|
| 5 |
from dataclasses import dataclass, field
|
| 6 |
|
| 7 |
+
from backend.config.settings import settings
|
| 8 |
|
| 9 |
+
mp = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
# ── Iris landmark indices ──────────────────────────────────────────────────────
|
| 13 |
# MediaPipe refine_landmarks=True adds iris landmarks 468-477
|
| 14 |
+
_LEFT_IRIS_CENTER = 468
|
| 15 |
_RIGHT_IRIS_CENTER = 473
|
| 16 |
|
| 17 |
# ── Screen region → bucket map ─────────────────────────────────────────────────
|
| 18 |
# Defined as (x_min, y_min, x_max, y_max) in normalised [0,1] coords
|
| 19 |
_REGION_BUCKET: list[tuple[tuple[float, float, float, float], str]] = [
|
| 20 |
+
((0.3, 0.3, 0.7, 0.7), "social"), # centre checked first (most specific)
|
| 21 |
((0.0, 0.0, 0.5, 0.5), "family"),
|
| 22 |
((0.5, 0.0, 1.0, 0.5), "medical"),
|
| 23 |
((0.0, 0.5, 0.5, 1.0), "hobbies"),
|
| 24 |
((0.5, 0.5, 1.0, 1.0), "daily_routine"),
|
|
|
|
| 25 |
]
|
| 26 |
|
| 27 |
|
|
|
|
| 31 |
Stateful gaze tracker. Call `process_frame` each frame.
|
| 32 |
Returns the bucket name when dwell threshold is exceeded, else None.
|
| 33 |
"""
|
| 34 |
+
|
| 35 |
_dwell_start: float = field(default=0.0)
|
| 36 |
_current_region: str | None = field(default=None)
|
| 37 |
|
| 38 |
def __post_init__(self):
|
| 39 |
+
global mp
|
| 40 |
+
import mediapipe as mp
|
| 41 |
+
|
| 42 |
self._face_mesh = mp.solutions.face_mesh.FaceMesh(
|
| 43 |
static_image_mode=False,
|
| 44 |
max_num_faces=1,
|
|
|
|
| 48 |
)
|
| 49 |
|
| 50 |
def process_frame(self, bgr_frame) -> str | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
import cv2
|
| 52 |
+
|
| 53 |
rgb = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB)
|
| 54 |
result = self._face_mesh.process(rgb)
|
| 55 |
|
{sensing → backend/sensing}/gesture.py
RENAMED
|
@@ -1,34 +1,17 @@
|
|
| 1 |
-
|
| 2 |
-
L1 — Hand gesture recognition via MediaPipe Hands.
|
| 3 |
-
|
| 4 |
-
Recognises 4 gestures from 21 3D hand landmarks at ~15 fps using
|
| 5 |
-
normalised joint-angle rules (no ML model needed at this stage):
|
| 6 |
-
|
| 7 |
-
THUMBS_UP → [TONE:AFFIRMATIVE]
|
| 8 |
-
THUMBS_DOWN → [TONE:NEGATIVE]
|
| 9 |
-
POINTING → [INTENT:REFERENTIAL]
|
| 10 |
-
WAVING → [INTENT:GREETING]
|
| 11 |
-
|
| 12 |
-
Each detected gesture is mapped to a stylistic constraint tag that is
|
| 13 |
-
injected into the generation prompt by the planner node.
|
| 14 |
-
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
import numpy as np
|
| 18 |
|
| 19 |
-
|
| 20 |
-
import mediapipe as mp
|
| 21 |
-
_MP_AVAILABLE = True
|
| 22 |
-
except ImportError:
|
| 23 |
-
_MP_AVAILABLE = False
|
| 24 |
|
| 25 |
|
| 26 |
# Gesture → prompt constraint tag mapping
|
| 27 |
GESTURE_TO_TAG: dict[str, str] = {
|
| 28 |
-
"THUMBS_UP":
|
| 29 |
"THUMBS_DOWN": "[GESTURE:THUMBS_DOWN][TONE:NEGATIVE]",
|
| 30 |
-
"POINTING":
|
| 31 |
-
"WAVING":
|
| 32 |
}
|
| 33 |
|
| 34 |
|
|
@@ -39,8 +22,9 @@ class GestureClassifier:
|
|
| 39 |
"""
|
| 40 |
|
| 41 |
def __init__(self):
|
| 42 |
-
|
| 43 |
-
|
|
|
|
| 44 |
self._hands = mp.solutions.hands.Hands(
|
| 45 |
static_image_mode=False,
|
| 46 |
max_num_hands=1,
|
|
@@ -53,6 +37,7 @@ class GestureClassifier:
|
|
| 53 |
Returns a gesture label string or None if no clear gesture is detected.
|
| 54 |
"""
|
| 55 |
import cv2
|
|
|
|
| 56 |
rgb = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB)
|
| 57 |
result = self._hands.process(rgb)
|
| 58 |
|
|
@@ -71,27 +56,21 @@ class GestureClassifier:
|
|
| 71 |
|
| 72 |
@staticmethod
|
| 73 |
def _classify(pts: np.ndarray) -> str | None:
|
| 74 |
-
"""
|
| 75 |
-
Rule-based gesture classification over normalised joint positions.
|
| 76 |
-
|
| 77 |
-
MediaPipe hand landmark indices:
|
| 78 |
-
0=WRIST, 1-4=THUMB, 5-8=INDEX, 9-12=MIDDLE, 13-16=RING, 17-20=PINKY
|
| 79 |
-
"""
|
| 80 |
# Normalise: wrist at origin, scale by palm width
|
| 81 |
wrist = pts[0]
|
| 82 |
palm_width = np.linalg.norm(pts[5] - pts[17]) + 1e-6
|
| 83 |
p = (pts - wrist) / palm_width
|
| 84 |
|
| 85 |
-
thumb_tip
|
| 86 |
-
index_tip
|
| 87 |
-
middle_tip
|
| 88 |
-
ring_tip
|
| 89 |
-
pinky_tip
|
| 90 |
-
index_mcp
|
| 91 |
|
| 92 |
# THUMBS_UP: thumb tip above wrist, other fingers curled
|
| 93 |
fingers_curled = all(
|
| 94 |
-
np.linalg.norm(tip) < np.linalg.norm(
|
| 95 |
for tip, mcp in [(index_tip, p[5]), (middle_tip, p[9]), (ring_tip, p[13])]
|
| 96 |
)
|
| 97 |
if thumb_tip[1] < -0.3 and fingers_curled:
|
|
@@ -103,9 +82,8 @@ class GestureClassifier:
|
|
| 103 |
|
| 104 |
# POINTING: index extended, others curled
|
| 105 |
index_extended = np.linalg.norm(index_tip) > np.linalg.norm(index_mcp) * 1.3
|
| 106 |
-
others_curled
|
| 107 |
-
np.linalg.norm(tip) < 0.5
|
| 108 |
-
for tip in [middle_tip, ring_tip, pinky_tip]
|
| 109 |
)
|
| 110 |
if index_extended and others_curled:
|
| 111 |
return "POINTING"
|
|
|
|
| 1 |
+
# Hand gesture recognition via MediaPipe Hands.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import numpy as np
|
| 5 |
|
| 6 |
+
mp = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
# Gesture → prompt constraint tag mapping
|
| 10 |
GESTURE_TO_TAG: dict[str, str] = {
|
| 11 |
+
"THUMBS_UP": "[GESTURE:THUMBS_UP][TONE:AFFIRMATIVE]",
|
| 12 |
"THUMBS_DOWN": "[GESTURE:THUMBS_DOWN][TONE:NEGATIVE]",
|
| 13 |
+
"POINTING": "[GESTURE:POINTING][INTENT:REFERENTIAL]",
|
| 14 |
+
"WAVING": "[GESTURE:WAVING][INTENT:GREETING]",
|
| 15 |
}
|
| 16 |
|
| 17 |
|
|
|
|
| 22 |
"""
|
| 23 |
|
| 24 |
def __init__(self):
|
| 25 |
+
global mp
|
| 26 |
+
import mediapipe as mp
|
| 27 |
+
|
| 28 |
self._hands = mp.solutions.hands.Hands(
|
| 29 |
static_image_mode=False,
|
| 30 |
max_num_hands=1,
|
|
|
|
| 37 |
Returns a gesture label string or None if no clear gesture is detected.
|
| 38 |
"""
|
| 39 |
import cv2
|
| 40 |
+
|
| 41 |
rgb = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2RGB)
|
| 42 |
result = self._hands.process(rgb)
|
| 43 |
|
|
|
|
| 56 |
|
| 57 |
@staticmethod
|
| 58 |
def _classify(pts: np.ndarray) -> str | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# Normalise: wrist at origin, scale by palm width
|
| 60 |
wrist = pts[0]
|
| 61 |
palm_width = np.linalg.norm(pts[5] - pts[17]) + 1e-6
|
| 62 |
p = (pts - wrist) / palm_width
|
| 63 |
|
| 64 |
+
thumb_tip = p[4]
|
| 65 |
+
index_tip = p[8]
|
| 66 |
+
middle_tip = p[12]
|
| 67 |
+
ring_tip = p[16]
|
| 68 |
+
pinky_tip = p[20]
|
| 69 |
+
index_mcp = p[5] # knuckle
|
| 70 |
|
| 71 |
# THUMBS_UP: thumb tip above wrist, other fingers curled
|
| 72 |
fingers_curled = all(
|
| 73 |
+
np.linalg.norm(tip) < np.linalg.norm(mcp)
|
| 74 |
for tip, mcp in [(index_tip, p[5]), (middle_tip, p[9]), (ring_tip, p[13])]
|
| 75 |
)
|
| 76 |
if thumb_tip[1] < -0.3 and fingers_curled:
|
|
|
|
| 82 |
|
| 83 |
# POINTING: index extended, others curled
|
| 84 |
index_extended = np.linalg.norm(index_tip) > np.linalg.norm(index_mcp) * 1.3
|
| 85 |
+
others_curled = all(
|
| 86 |
+
np.linalg.norm(tip) < 0.5 for tip in [middle_tip, ring_tip, pinky_tip]
|
|
|
|
| 87 |
)
|
| 88 |
if index_extended and others_curled:
|
| 89 |
return "POINTING"
|
{ui → backend/ui}/app.py
RENAMED
|
@@ -8,10 +8,8 @@ Panels:
|
|
| 8 |
|
| 9 |
Run: streamlit run ui/app.py
|
| 10 |
"""
|
| 11 |
-
from __future__ import annotations
|
| 12 |
|
| 13 |
-
import
|
| 14 |
-
import time
|
| 15 |
|
| 16 |
import requests
|
| 17 |
import streamlit as st
|
|
@@ -52,8 +50,11 @@ with st.sidebar:
|
|
| 52 |
st.error("API not reachable — start the FastAPI server first.")
|
| 53 |
|
| 54 |
user_options = {u["id"]: f"{u['name']} ({u['condition']})" for u in users}
|
| 55 |
-
selected = st.selectbox(
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
if selected != st.session_state.user_id:
|
| 59 |
st.session_state.user_id = selected
|
|
@@ -73,15 +74,19 @@ with st.sidebar:
|
|
| 73 |
["Auto (webcam)", "HAPPY", "FRUSTRATED", "NEUTRAL", "SURPRISED"],
|
| 74 |
index=0,
|
| 75 |
)
|
| 76 |
-
st.session_state.affect_override =
|
|
|
|
|
|
|
| 77 |
|
| 78 |
st.divider()
|
| 79 |
|
| 80 |
# Live affect indicator
|
| 81 |
st.subheader("Detected Affect")
|
| 82 |
affect_emoji = {
|
| 83 |
-
"HAPPY": "😊",
|
| 84 |
-
"
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
af = st.session_state.last_affect
|
| 87 |
st.markdown(f"### {affect_emoji.get(af, '❓')} {af}")
|
|
@@ -89,7 +94,9 @@ with st.sidebar:
|
|
| 89 |
# Webcam placeholder
|
| 90 |
st.divider()
|
| 91 |
st.subheader("Webcam Feed")
|
| 92 |
-
st.info(
|
|
|
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
# ── Main chat area ─────────────────────────────────────────────────────────────
|
|
@@ -119,12 +126,15 @@ with chat_col:
|
|
| 119 |
"affect_override": st.session_state.affect_override,
|
| 120 |
}
|
| 121 |
resp = requests.post(f"{API_BASE}/chat", json=payload, timeout=15)
|
|
|
|
| 122 |
data = resp.json()
|
| 123 |
|
| 124 |
response_text = data.get("response", "I don't know.")
|
| 125 |
st.markdown(f"**AAC User:** {response_text}")
|
| 126 |
|
| 127 |
-
st.session_state.messages.append(
|
|
|
|
|
|
|
| 128 |
st.session_state.last_affect = data.get("affect", "NEUTRAL")
|
| 129 |
st.session_state.last_latency = data.get("latency", {})
|
| 130 |
|
|
@@ -141,11 +151,11 @@ with metrics_col:
|
|
| 141 |
lat = st.session_state.last_latency
|
| 142 |
if lat:
|
| 143 |
for key, label in [
|
| 144 |
-
("t_sensing",
|
| 145 |
-
("t_intent",
|
| 146 |
-
("t_retrieval",
|
| 147 |
("t_generation", "Generation"),
|
| 148 |
-
("t_total",
|
| 149 |
]:
|
| 150 |
val = lat.get(key, 0.0)
|
| 151 |
st.metric(label=label, value=f"{val:.3f}s")
|
|
|
|
| 8 |
|
| 9 |
Run: streamlit run ui/app.py
|
| 10 |
"""
|
|
|
|
| 11 |
|
| 12 |
+
from __future__ import annotations
|
|
|
|
| 13 |
|
| 14 |
import requests
|
| 15 |
import streamlit as st
|
|
|
|
| 50 |
st.error("API not reachable — start the FastAPI server first.")
|
| 51 |
|
| 52 |
user_options = {u["id"]: f"{u['name']} ({u['condition']})" for u in users}
|
| 53 |
+
selected = st.selectbox(
|
| 54 |
+
"Select persona",
|
| 55 |
+
options=list(user_options.keys()),
|
| 56 |
+
format_func=lambda k: user_options.get(k, k),
|
| 57 |
+
)
|
| 58 |
|
| 59 |
if selected != st.session_state.user_id:
|
| 60 |
st.session_state.user_id = selected
|
|
|
|
| 74 |
["Auto (webcam)", "HAPPY", "FRUSTRATED", "NEUTRAL", "SURPRISED"],
|
| 75 |
index=0,
|
| 76 |
)
|
| 77 |
+
st.session_state.affect_override = (
|
| 78 |
+
None if affect_choice == "Auto (webcam)" else affect_choice
|
| 79 |
+
)
|
| 80 |
|
| 81 |
st.divider()
|
| 82 |
|
| 83 |
# Live affect indicator
|
| 84 |
st.subheader("Detected Affect")
|
| 85 |
affect_emoji = {
|
| 86 |
+
"HAPPY": "😊",
|
| 87 |
+
"FRUSTRATED": "😤",
|
| 88 |
+
"NEUTRAL": "😐",
|
| 89 |
+
"SURPRISED": "😲",
|
| 90 |
}
|
| 91 |
af = st.session_state.last_affect
|
| 92 |
st.markdown(f"### {affect_emoji.get(af, '❓')} {af}")
|
|
|
|
| 94 |
# Webcam placeholder
|
| 95 |
st.divider()
|
| 96 |
st.subheader("Webcam Feed")
|
| 97 |
+
st.info(
|
| 98 |
+
"Live webcam sensing runs in the sensing client.\nAffect is sent to the API automatically."
|
| 99 |
+
)
|
| 100 |
|
| 101 |
|
| 102 |
# ── Main chat area ─────────────────────────────────────────────────────────────
|
|
|
|
| 126 |
"affect_override": st.session_state.affect_override,
|
| 127 |
}
|
| 128 |
resp = requests.post(f"{API_BASE}/chat", json=payload, timeout=15)
|
| 129 |
+
resp.raise_for_status()
|
| 130 |
data = resp.json()
|
| 131 |
|
| 132 |
response_text = data.get("response", "I don't know.")
|
| 133 |
st.markdown(f"**AAC User:** {response_text}")
|
| 134 |
|
| 135 |
+
st.session_state.messages.append(
|
| 136 |
+
{"role": "aac_user", "content": response_text}
|
| 137 |
+
)
|
| 138 |
st.session_state.last_affect = data.get("affect", "NEUTRAL")
|
| 139 |
st.session_state.last_latency = data.get("latency", {})
|
| 140 |
|
|
|
|
| 151 |
lat = st.session_state.last_latency
|
| 152 |
if lat:
|
| 153 |
for key, label in [
|
| 154 |
+
("t_sensing", "Sensing"),
|
| 155 |
+
("t_intent", "Intent"),
|
| 156 |
+
("t_retrieval", "Retrieval"),
|
| 157 |
("t_generation", "Generation"),
|
| 158 |
+
("t_total", "**Total**"),
|
| 159 |
]:
|
| 160 |
val = lat.get(key, 0.0)
|
| 161 |
st.metric(label=label, value=f"{val:.3f}s")
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@mediapipe/tasks-vision": "^0.10.34",
|
| 14 |
+
"react": "^19.2.4",
|
| 15 |
+
"react-dom": "^19.2.4"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@eslint/js": "^9.39.4",
|
| 19 |
+
"@types/node": "^24.12.2",
|
| 20 |
+
"@types/react": "^19.2.14",
|
| 21 |
+
"@types/react-dom": "^19.2.3",
|
| 22 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 23 |
+
"eslint": "^9.39.4",
|
| 24 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 25 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 26 |
+
"globals": "^17.4.0",
|
| 27 |
+
"typescript": "~6.0.2",
|
| 28 |
+
"typescript-eslint": "^8.58.0",
|
| 29 |
+
"vite": "^8.0.8"
|
| 30 |
+
}
|
| 31 |
+
}
|
frontend/pnpm-lock.yaml
ADDED
|
@@ -0,0 +1,1863 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
lockfileVersion: '9.0'
|
| 2 |
+
|
| 3 |
+
settings:
|
| 4 |
+
autoInstallPeers: true
|
| 5 |
+
excludeLinksFromLockfile: false
|
| 6 |
+
|
| 7 |
+
importers:
|
| 8 |
+
|
| 9 |
+
.:
|
| 10 |
+
dependencies:
|
| 11 |
+
'@mediapipe/tasks-vision':
|
| 12 |
+
specifier: ^0.10.34
|
| 13 |
+
version: 0.10.34
|
| 14 |
+
react:
|
| 15 |
+
specifier: ^19.2.4
|
| 16 |
+
version: 19.2.5
|
| 17 |
+
react-dom:
|
| 18 |
+
specifier: ^19.2.4
|
| 19 |
+
version: 19.2.5(react@19.2.5)
|
| 20 |
+
devDependencies:
|
| 21 |
+
'@eslint/js':
|
| 22 |
+
specifier: ^9.39.4
|
| 23 |
+
version: 9.39.4
|
| 24 |
+
'@types/node':
|
| 25 |
+
specifier: ^24.12.2
|
| 26 |
+
version: 24.12.2
|
| 27 |
+
'@types/react':
|
| 28 |
+
specifier: ^19.2.14
|
| 29 |
+
version: 19.2.14
|
| 30 |
+
'@types/react-dom':
|
| 31 |
+
specifier: ^19.2.3
|
| 32 |
+
version: 19.2.3(@types/react@19.2.14)
|
| 33 |
+
'@vitejs/plugin-react':
|
| 34 |
+
specifier: ^6.0.1
|
| 35 |
+
version: 6.0.1(vite@8.0.8(@types/node@24.12.2))
|
| 36 |
+
eslint:
|
| 37 |
+
specifier: ^9.39.4
|
| 38 |
+
version: 9.39.4
|
| 39 |
+
eslint-plugin-react-hooks:
|
| 40 |
+
specifier: ^7.0.1
|
| 41 |
+
version: 7.0.1(eslint@9.39.4)
|
| 42 |
+
eslint-plugin-react-refresh:
|
| 43 |
+
specifier: ^0.5.2
|
| 44 |
+
version: 0.5.2(eslint@9.39.4)
|
| 45 |
+
globals:
|
| 46 |
+
specifier: ^17.4.0
|
| 47 |
+
version: 17.5.0
|
| 48 |
+
typescript:
|
| 49 |
+
specifier: ~6.0.2
|
| 50 |
+
version: 6.0.2
|
| 51 |
+
typescript-eslint:
|
| 52 |
+
specifier: ^8.58.0
|
| 53 |
+
version: 8.58.2(eslint@9.39.4)(typescript@6.0.2)
|
| 54 |
+
vite:
|
| 55 |
+
specifier: ^8.0.8
|
| 56 |
+
version: 8.0.8(@types/node@24.12.2)
|
| 57 |
+
|
| 58 |
+
packages:
|
| 59 |
+
|
| 60 |
+
'@babel/code-frame@7.29.0':
|
| 61 |
+
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
| 62 |
+
engines: {node: '>=6.9.0'}
|
| 63 |
+
|
| 64 |
+
'@babel/compat-data@7.29.0':
|
| 65 |
+
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
|
| 66 |
+
engines: {node: '>=6.9.0'}
|
| 67 |
+
|
| 68 |
+
'@babel/core@7.29.0':
|
| 69 |
+
resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
|
| 70 |
+
engines: {node: '>=6.9.0'}
|
| 71 |
+
|
| 72 |
+
'@babel/generator@7.29.1':
|
| 73 |
+
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
|
| 74 |
+
engines: {node: '>=6.9.0'}
|
| 75 |
+
|
| 76 |
+
'@babel/helper-compilation-targets@7.28.6':
|
| 77 |
+
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
|
| 78 |
+
engines: {node: '>=6.9.0'}
|
| 79 |
+
|
| 80 |
+
'@babel/helper-globals@7.28.0':
|
| 81 |
+
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
|
| 82 |
+
engines: {node: '>=6.9.0'}
|
| 83 |
+
|
| 84 |
+
'@babel/helper-module-imports@7.28.6':
|
| 85 |
+
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
|
| 86 |
+
engines: {node: '>=6.9.0'}
|
| 87 |
+
|
| 88 |
+
'@babel/helper-module-transforms@7.28.6':
|
| 89 |
+
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
|
| 90 |
+
engines: {node: '>=6.9.0'}
|
| 91 |
+
peerDependencies:
|
| 92 |
+
'@babel/core': ^7.0.0
|
| 93 |
+
|
| 94 |
+
'@babel/helper-string-parser@7.27.1':
|
| 95 |
+
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
| 96 |
+
engines: {node: '>=6.9.0'}
|
| 97 |
+
|
| 98 |
+
'@babel/helper-validator-identifier@7.28.5':
|
| 99 |
+
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
| 100 |
+
engines: {node: '>=6.9.0'}
|
| 101 |
+
|
| 102 |
+
'@babel/helper-validator-option@7.27.1':
|
| 103 |
+
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
|
| 104 |
+
engines: {node: '>=6.9.0'}
|
| 105 |
+
|
| 106 |
+
'@babel/helpers@7.29.2':
|
| 107 |
+
resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
|
| 108 |
+
engines: {node: '>=6.9.0'}
|
| 109 |
+
|
| 110 |
+
'@babel/parser@7.29.2':
|
| 111 |
+
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
| 112 |
+
engines: {node: '>=6.0.0'}
|
| 113 |
+
hasBin: true
|
| 114 |
+
|
| 115 |
+
'@babel/template@7.28.6':
|
| 116 |
+
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
| 117 |
+
engines: {node: '>=6.9.0'}
|
| 118 |
+
|
| 119 |
+
'@babel/traverse@7.29.0':
|
| 120 |
+
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
|
| 121 |
+
engines: {node: '>=6.9.0'}
|
| 122 |
+
|
| 123 |
+
'@babel/types@7.29.0':
|
| 124 |
+
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
| 125 |
+
engines: {node: '>=6.9.0'}
|
| 126 |
+
|
| 127 |
+
'@emnapi/core@1.9.2':
|
| 128 |
+
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
| 129 |
+
|
| 130 |
+
'@emnapi/runtime@1.9.2':
|
| 131 |
+
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
|
| 132 |
+
|
| 133 |
+
'@emnapi/wasi-threads@1.2.1':
|
| 134 |
+
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
| 135 |
+
|
| 136 |
+
'@eslint-community/eslint-utils@4.9.1':
|
| 137 |
+
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
|
| 138 |
+
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
| 139 |
+
peerDependencies:
|
| 140 |
+
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
| 141 |
+
|
| 142 |
+
'@eslint-community/regexpp@4.12.2':
|
| 143 |
+
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
|
| 144 |
+
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
| 145 |
+
|
| 146 |
+
'@eslint/config-array@0.21.2':
|
| 147 |
+
resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
|
| 148 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 149 |
+
|
| 150 |
+
'@eslint/config-helpers@0.4.2':
|
| 151 |
+
resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
|
| 152 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 153 |
+
|
| 154 |
+
'@eslint/core@0.17.0':
|
| 155 |
+
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
|
| 156 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 157 |
+
|
| 158 |
+
'@eslint/eslintrc@3.3.5':
|
| 159 |
+
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
|
| 160 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 161 |
+
|
| 162 |
+
'@eslint/js@9.39.4':
|
| 163 |
+
resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
|
| 164 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 165 |
+
|
| 166 |
+
'@eslint/object-schema@2.1.7':
|
| 167 |
+
resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
|
| 168 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 169 |
+
|
| 170 |
+
'@eslint/plugin-kit@0.4.1':
|
| 171 |
+
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
| 172 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 173 |
+
|
| 174 |
+
'@humanfs/core@0.19.1':
|
| 175 |
+
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
| 176 |
+
engines: {node: '>=18.18.0'}
|
| 177 |
+
|
| 178 |
+
'@humanfs/node@0.16.7':
|
| 179 |
+
resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
|
| 180 |
+
engines: {node: '>=18.18.0'}
|
| 181 |
+
|
| 182 |
+
'@humanwhocodes/module-importer@1.0.1':
|
| 183 |
+
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
|
| 184 |
+
engines: {node: '>=12.22'}
|
| 185 |
+
|
| 186 |
+
'@humanwhocodes/retry@0.4.3':
|
| 187 |
+
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
| 188 |
+
engines: {node: '>=18.18'}
|
| 189 |
+
|
| 190 |
+
'@jridgewell/gen-mapping@0.3.13':
|
| 191 |
+
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
| 192 |
+
|
| 193 |
+
'@jridgewell/remapping@2.3.5':
|
| 194 |
+
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
| 195 |
+
|
| 196 |
+
'@jridgewell/resolve-uri@3.1.2':
|
| 197 |
+
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
| 198 |
+
engines: {node: '>=6.0.0'}
|
| 199 |
+
|
| 200 |
+
'@jridgewell/sourcemap-codec@1.5.5':
|
| 201 |
+
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
| 202 |
+
|
| 203 |
+
'@jridgewell/trace-mapping@0.3.31':
|
| 204 |
+
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
| 205 |
+
|
| 206 |
+
'@mediapipe/tasks-vision@0.10.34':
|
| 207 |
+
resolution: {integrity: sha512-KFGyhDsjJ+9WUMcMfjTOpcEp3LJNS3KwC7BfvKrCYELn/7G/5kmwnU7z6Spps+iWQoTGL8xW8i68r65OTa3DwA==}
|
| 208 |
+
|
| 209 |
+
'@napi-rs/wasm-runtime@1.1.4':
|
| 210 |
+
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
| 211 |
+
peerDependencies:
|
| 212 |
+
'@emnapi/core': ^1.7.1
|
| 213 |
+
'@emnapi/runtime': ^1.7.1
|
| 214 |
+
|
| 215 |
+
'@oxc-project/types@0.124.0':
|
| 216 |
+
resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==}
|
| 217 |
+
|
| 218 |
+
'@rolldown/binding-android-arm64@1.0.0-rc.15':
|
| 219 |
+
resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==}
|
| 220 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 221 |
+
cpu: [arm64]
|
| 222 |
+
os: [android]
|
| 223 |
+
|
| 224 |
+
'@rolldown/binding-darwin-arm64@1.0.0-rc.15':
|
| 225 |
+
resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==}
|
| 226 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 227 |
+
cpu: [arm64]
|
| 228 |
+
os: [darwin]
|
| 229 |
+
|
| 230 |
+
'@rolldown/binding-darwin-x64@1.0.0-rc.15':
|
| 231 |
+
resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==}
|
| 232 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 233 |
+
cpu: [x64]
|
| 234 |
+
os: [darwin]
|
| 235 |
+
|
| 236 |
+
'@rolldown/binding-freebsd-x64@1.0.0-rc.15':
|
| 237 |
+
resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==}
|
| 238 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 239 |
+
cpu: [x64]
|
| 240 |
+
os: [freebsd]
|
| 241 |
+
|
| 242 |
+
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15':
|
| 243 |
+
resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==}
|
| 244 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 245 |
+
cpu: [arm]
|
| 246 |
+
os: [linux]
|
| 247 |
+
|
| 248 |
+
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15':
|
| 249 |
+
resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==}
|
| 250 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 251 |
+
cpu: [arm64]
|
| 252 |
+
os: [linux]
|
| 253 |
+
libc: [glibc]
|
| 254 |
+
|
| 255 |
+
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
|
| 256 |
+
resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==}
|
| 257 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 258 |
+
cpu: [arm64]
|
| 259 |
+
os: [linux]
|
| 260 |
+
libc: [musl]
|
| 261 |
+
|
| 262 |
+
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
|
| 263 |
+
resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==}
|
| 264 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 265 |
+
cpu: [ppc64]
|
| 266 |
+
os: [linux]
|
| 267 |
+
libc: [glibc]
|
| 268 |
+
|
| 269 |
+
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
|
| 270 |
+
resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==}
|
| 271 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 272 |
+
cpu: [s390x]
|
| 273 |
+
os: [linux]
|
| 274 |
+
libc: [glibc]
|
| 275 |
+
|
| 276 |
+
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
|
| 277 |
+
resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==}
|
| 278 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 279 |
+
cpu: [x64]
|
| 280 |
+
os: [linux]
|
| 281 |
+
libc: [glibc]
|
| 282 |
+
|
| 283 |
+
'@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
|
| 284 |
+
resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==}
|
| 285 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 286 |
+
cpu: [x64]
|
| 287 |
+
os: [linux]
|
| 288 |
+
libc: [musl]
|
| 289 |
+
|
| 290 |
+
'@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
|
| 291 |
+
resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==}
|
| 292 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 293 |
+
cpu: [arm64]
|
| 294 |
+
os: [openharmony]
|
| 295 |
+
|
| 296 |
+
'@rolldown/binding-wasm32-wasi@1.0.0-rc.15':
|
| 297 |
+
resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==}
|
| 298 |
+
engines: {node: '>=14.0.0'}
|
| 299 |
+
cpu: [wasm32]
|
| 300 |
+
|
| 301 |
+
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
|
| 302 |
+
resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==}
|
| 303 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 304 |
+
cpu: [arm64]
|
| 305 |
+
os: [win32]
|
| 306 |
+
|
| 307 |
+
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.15':
|
| 308 |
+
resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==}
|
| 309 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 310 |
+
cpu: [x64]
|
| 311 |
+
os: [win32]
|
| 312 |
+
|
| 313 |
+
'@rolldown/pluginutils@1.0.0-rc.15':
|
| 314 |
+
resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==}
|
| 315 |
+
|
| 316 |
+
'@rolldown/pluginutils@1.0.0-rc.7':
|
| 317 |
+
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
| 318 |
+
|
| 319 |
+
'@tybys/wasm-util@0.10.1':
|
| 320 |
+
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
| 321 |
+
|
| 322 |
+
'@types/estree@1.0.8':
|
| 323 |
+
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
| 324 |
+
|
| 325 |
+
'@types/json-schema@7.0.15':
|
| 326 |
+
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
| 327 |
+
|
| 328 |
+
'@types/node@24.12.2':
|
| 329 |
+
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
| 330 |
+
|
| 331 |
+
'@types/react-dom@19.2.3':
|
| 332 |
+
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
| 333 |
+
peerDependencies:
|
| 334 |
+
'@types/react': ^19.2.0
|
| 335 |
+
|
| 336 |
+
'@types/react@19.2.14':
|
| 337 |
+
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
| 338 |
+
|
| 339 |
+
'@typescript-eslint/eslint-plugin@8.58.2':
|
| 340 |
+
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
|
| 341 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 342 |
+
peerDependencies:
|
| 343 |
+
'@typescript-eslint/parser': ^8.58.2
|
| 344 |
+
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
| 345 |
+
typescript: '>=4.8.4 <6.1.0'
|
| 346 |
+
|
| 347 |
+
'@typescript-eslint/parser@8.58.2':
|
| 348 |
+
resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==}
|
| 349 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 350 |
+
peerDependencies:
|
| 351 |
+
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
| 352 |
+
typescript: '>=4.8.4 <6.1.0'
|
| 353 |
+
|
| 354 |
+
'@typescript-eslint/project-service@8.58.2':
|
| 355 |
+
resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==}
|
| 356 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 357 |
+
peerDependencies:
|
| 358 |
+
typescript: '>=4.8.4 <6.1.0'
|
| 359 |
+
|
| 360 |
+
'@typescript-eslint/scope-manager@8.58.2':
|
| 361 |
+
resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==}
|
| 362 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 363 |
+
|
| 364 |
+
'@typescript-eslint/tsconfig-utils@8.58.2':
|
| 365 |
+
resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==}
|
| 366 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 367 |
+
peerDependencies:
|
| 368 |
+
typescript: '>=4.8.4 <6.1.0'
|
| 369 |
+
|
| 370 |
+
'@typescript-eslint/type-utils@8.58.2':
|
| 371 |
+
resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==}
|
| 372 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 373 |
+
peerDependencies:
|
| 374 |
+
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
| 375 |
+
typescript: '>=4.8.4 <6.1.0'
|
| 376 |
+
|
| 377 |
+
'@typescript-eslint/types@8.58.2':
|
| 378 |
+
resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==}
|
| 379 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 380 |
+
|
| 381 |
+
'@typescript-eslint/typescript-estree@8.58.2':
|
| 382 |
+
resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==}
|
| 383 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 384 |
+
peerDependencies:
|
| 385 |
+
typescript: '>=4.8.4 <6.1.0'
|
| 386 |
+
|
| 387 |
+
'@typescript-eslint/utils@8.58.2':
|
| 388 |
+
resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==}
|
| 389 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 390 |
+
peerDependencies:
|
| 391 |
+
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
| 392 |
+
typescript: '>=4.8.4 <6.1.0'
|
| 393 |
+
|
| 394 |
+
'@typescript-eslint/visitor-keys@8.58.2':
|
| 395 |
+
resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==}
|
| 396 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 397 |
+
|
| 398 |
+
'@vitejs/plugin-react@6.0.1':
|
| 399 |
+
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
|
| 400 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 401 |
+
peerDependencies:
|
| 402 |
+
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
|
| 403 |
+
babel-plugin-react-compiler: ^1.0.0
|
| 404 |
+
vite: ^8.0.0
|
| 405 |
+
peerDependenciesMeta:
|
| 406 |
+
'@rolldown/plugin-babel':
|
| 407 |
+
optional: true
|
| 408 |
+
babel-plugin-react-compiler:
|
| 409 |
+
optional: true
|
| 410 |
+
|
| 411 |
+
acorn-jsx@5.3.2:
|
| 412 |
+
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
| 413 |
+
peerDependencies:
|
| 414 |
+
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
|
| 415 |
+
|
| 416 |
+
acorn@8.16.0:
|
| 417 |
+
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
| 418 |
+
engines: {node: '>=0.4.0'}
|
| 419 |
+
hasBin: true
|
| 420 |
+
|
| 421 |
+
ajv@6.14.0:
|
| 422 |
+
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
| 423 |
+
|
| 424 |
+
ansi-styles@4.3.0:
|
| 425 |
+
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
| 426 |
+
engines: {node: '>=8'}
|
| 427 |
+
|
| 428 |
+
argparse@2.0.1:
|
| 429 |
+
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
| 430 |
+
|
| 431 |
+
balanced-match@1.0.2:
|
| 432 |
+
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
| 433 |
+
|
| 434 |
+
balanced-match@4.0.4:
|
| 435 |
+
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
| 436 |
+
engines: {node: 18 || 20 || >=22}
|
| 437 |
+
|
| 438 |
+
baseline-browser-mapping@2.10.19:
|
| 439 |
+
resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==}
|
| 440 |
+
engines: {node: '>=6.0.0'}
|
| 441 |
+
hasBin: true
|
| 442 |
+
|
| 443 |
+
brace-expansion@1.1.14:
|
| 444 |
+
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
| 445 |
+
|
| 446 |
+
brace-expansion@5.0.5:
|
| 447 |
+
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
|
| 448 |
+
engines: {node: 18 || 20 || >=22}
|
| 449 |
+
|
| 450 |
+
browserslist@4.28.2:
|
| 451 |
+
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
|
| 452 |
+
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
| 453 |
+
hasBin: true
|
| 454 |
+
|
| 455 |
+
callsites@3.1.0:
|
| 456 |
+
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
| 457 |
+
engines: {node: '>=6'}
|
| 458 |
+
|
| 459 |
+
caniuse-lite@1.0.30001788:
|
| 460 |
+
resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==}
|
| 461 |
+
|
| 462 |
+
chalk@4.1.2:
|
| 463 |
+
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
| 464 |
+
engines: {node: '>=10'}
|
| 465 |
+
|
| 466 |
+
color-convert@2.0.1:
|
| 467 |
+
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
| 468 |
+
engines: {node: '>=7.0.0'}
|
| 469 |
+
|
| 470 |
+
color-name@1.1.4:
|
| 471 |
+
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
| 472 |
+
|
| 473 |
+
concat-map@0.0.1:
|
| 474 |
+
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
| 475 |
+
|
| 476 |
+
convert-source-map@2.0.0:
|
| 477 |
+
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
| 478 |
+
|
| 479 |
+
cross-spawn@7.0.6:
|
| 480 |
+
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
| 481 |
+
engines: {node: '>= 8'}
|
| 482 |
+
|
| 483 |
+
csstype@3.2.3:
|
| 484 |
+
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
| 485 |
+
|
| 486 |
+
debug@4.4.3:
|
| 487 |
+
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
| 488 |
+
engines: {node: '>=6.0'}
|
| 489 |
+
peerDependencies:
|
| 490 |
+
supports-color: '*'
|
| 491 |
+
peerDependenciesMeta:
|
| 492 |
+
supports-color:
|
| 493 |
+
optional: true
|
| 494 |
+
|
| 495 |
+
deep-is@0.1.4:
|
| 496 |
+
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
| 497 |
+
|
| 498 |
+
detect-libc@2.1.2:
|
| 499 |
+
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
| 500 |
+
engines: {node: '>=8'}
|
| 501 |
+
|
| 502 |
+
electron-to-chromium@1.5.337:
|
| 503 |
+
resolution: {integrity: sha512-15gKW9mRUNP9RdzhedJNypFUxtYWSXohFz2nTLzM272xbRXHws68kNDzyATG3qej+vUj/7Sn9hf5XTDh0XK6/w==}
|
| 504 |
+
|
| 505 |
+
escalade@3.2.0:
|
| 506 |
+
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
| 507 |
+
engines: {node: '>=6'}
|
| 508 |
+
|
| 509 |
+
escape-string-regexp@4.0.0:
|
| 510 |
+
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
| 511 |
+
engines: {node: '>=10'}
|
| 512 |
+
|
| 513 |
+
eslint-plugin-react-hooks@7.0.1:
|
| 514 |
+
resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
|
| 515 |
+
engines: {node: '>=18'}
|
| 516 |
+
peerDependencies:
|
| 517 |
+
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
|
| 518 |
+
|
| 519 |
+
eslint-plugin-react-refresh@0.5.2:
|
| 520 |
+
resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
|
| 521 |
+
peerDependencies:
|
| 522 |
+
eslint: ^9 || ^10
|
| 523 |
+
|
| 524 |
+
eslint-scope@8.4.0:
|
| 525 |
+
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
|
| 526 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 527 |
+
|
| 528 |
+
eslint-visitor-keys@3.4.3:
|
| 529 |
+
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
|
| 530 |
+
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
| 531 |
+
|
| 532 |
+
eslint-visitor-keys@4.2.1:
|
| 533 |
+
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
| 534 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 535 |
+
|
| 536 |
+
eslint-visitor-keys@5.0.1:
|
| 537 |
+
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
|
| 538 |
+
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
| 539 |
+
|
| 540 |
+
eslint@9.39.4:
|
| 541 |
+
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
|
| 542 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 543 |
+
hasBin: true
|
| 544 |
+
peerDependencies:
|
| 545 |
+
jiti: '*'
|
| 546 |
+
peerDependenciesMeta:
|
| 547 |
+
jiti:
|
| 548 |
+
optional: true
|
| 549 |
+
|
| 550 |
+
espree@10.4.0:
|
| 551 |
+
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
| 552 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 553 |
+
|
| 554 |
+
esquery@1.7.0:
|
| 555 |
+
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
|
| 556 |
+
engines: {node: '>=0.10'}
|
| 557 |
+
|
| 558 |
+
esrecurse@4.3.0:
|
| 559 |
+
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
|
| 560 |
+
engines: {node: '>=4.0'}
|
| 561 |
+
|
| 562 |
+
estraverse@5.3.0:
|
| 563 |
+
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
| 564 |
+
engines: {node: '>=4.0'}
|
| 565 |
+
|
| 566 |
+
esutils@2.0.3:
|
| 567 |
+
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
| 568 |
+
engines: {node: '>=0.10.0'}
|
| 569 |
+
|
| 570 |
+
fast-deep-equal@3.1.3:
|
| 571 |
+
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
| 572 |
+
|
| 573 |
+
fast-json-stable-stringify@2.1.0:
|
| 574 |
+
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
| 575 |
+
|
| 576 |
+
fast-levenshtein@2.0.6:
|
| 577 |
+
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
| 578 |
+
|
| 579 |
+
fdir@6.5.0:
|
| 580 |
+
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
| 581 |
+
engines: {node: '>=12.0.0'}
|
| 582 |
+
peerDependencies:
|
| 583 |
+
picomatch: ^3 || ^4
|
| 584 |
+
peerDependenciesMeta:
|
| 585 |
+
picomatch:
|
| 586 |
+
optional: true
|
| 587 |
+
|
| 588 |
+
file-entry-cache@8.0.0:
|
| 589 |
+
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
| 590 |
+
engines: {node: '>=16.0.0'}
|
| 591 |
+
|
| 592 |
+
find-up@5.0.0:
|
| 593 |
+
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
| 594 |
+
engines: {node: '>=10'}
|
| 595 |
+
|
| 596 |
+
flat-cache@4.0.1:
|
| 597 |
+
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
| 598 |
+
engines: {node: '>=16'}
|
| 599 |
+
|
| 600 |
+
flatted@3.4.2:
|
| 601 |
+
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
| 602 |
+
|
| 603 |
+
fsevents@2.3.3:
|
| 604 |
+
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
| 605 |
+
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
| 606 |
+
os: [darwin]
|
| 607 |
+
|
| 608 |
+
gensync@1.0.0-beta.2:
|
| 609 |
+
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
| 610 |
+
engines: {node: '>=6.9.0'}
|
| 611 |
+
|
| 612 |
+
glob-parent@6.0.2:
|
| 613 |
+
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
| 614 |
+
engines: {node: '>=10.13.0'}
|
| 615 |
+
|
| 616 |
+
globals@14.0.0:
|
| 617 |
+
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
| 618 |
+
engines: {node: '>=18'}
|
| 619 |
+
|
| 620 |
+
globals@17.5.0:
|
| 621 |
+
resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==}
|
| 622 |
+
engines: {node: '>=18'}
|
| 623 |
+
|
| 624 |
+
has-flag@4.0.0:
|
| 625 |
+
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
| 626 |
+
engines: {node: '>=8'}
|
| 627 |
+
|
| 628 |
+
hermes-estree@0.25.1:
|
| 629 |
+
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
| 630 |
+
|
| 631 |
+
hermes-parser@0.25.1:
|
| 632 |
+
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
| 633 |
+
|
| 634 |
+
ignore@5.3.2:
|
| 635 |
+
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
| 636 |
+
engines: {node: '>= 4'}
|
| 637 |
+
|
| 638 |
+
ignore@7.0.5:
|
| 639 |
+
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
| 640 |
+
engines: {node: '>= 4'}
|
| 641 |
+
|
| 642 |
+
import-fresh@3.3.1:
|
| 643 |
+
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
| 644 |
+
engines: {node: '>=6'}
|
| 645 |
+
|
| 646 |
+
imurmurhash@0.1.4:
|
| 647 |
+
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
| 648 |
+
engines: {node: '>=0.8.19'}
|
| 649 |
+
|
| 650 |
+
is-extglob@2.1.1:
|
| 651 |
+
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
| 652 |
+
engines: {node: '>=0.10.0'}
|
| 653 |
+
|
| 654 |
+
is-glob@4.0.3:
|
| 655 |
+
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
| 656 |
+
engines: {node: '>=0.10.0'}
|
| 657 |
+
|
| 658 |
+
isexe@2.0.0:
|
| 659 |
+
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
| 660 |
+
|
| 661 |
+
js-tokens@4.0.0:
|
| 662 |
+
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
| 663 |
+
|
| 664 |
+
js-yaml@4.1.1:
|
| 665 |
+
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
| 666 |
+
hasBin: true
|
| 667 |
+
|
| 668 |
+
jsesc@3.1.0:
|
| 669 |
+
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
| 670 |
+
engines: {node: '>=6'}
|
| 671 |
+
hasBin: true
|
| 672 |
+
|
| 673 |
+
json-buffer@3.0.1:
|
| 674 |
+
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
| 675 |
+
|
| 676 |
+
json-schema-traverse@0.4.1:
|
| 677 |
+
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
| 678 |
+
|
| 679 |
+
json-stable-stringify-without-jsonify@1.0.1:
|
| 680 |
+
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
| 681 |
+
|
| 682 |
+
json5@2.2.3:
|
| 683 |
+
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
| 684 |
+
engines: {node: '>=6'}
|
| 685 |
+
hasBin: true
|
| 686 |
+
|
| 687 |
+
keyv@4.5.4:
|
| 688 |
+
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
| 689 |
+
|
| 690 |
+
levn@0.4.1:
|
| 691 |
+
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
| 692 |
+
engines: {node: '>= 0.8.0'}
|
| 693 |
+
|
| 694 |
+
lightningcss-android-arm64@1.32.0:
|
| 695 |
+
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
| 696 |
+
engines: {node: '>= 12.0.0'}
|
| 697 |
+
cpu: [arm64]
|
| 698 |
+
os: [android]
|
| 699 |
+
|
| 700 |
+
lightningcss-darwin-arm64@1.32.0:
|
| 701 |
+
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
|
| 702 |
+
engines: {node: '>= 12.0.0'}
|
| 703 |
+
cpu: [arm64]
|
| 704 |
+
os: [darwin]
|
| 705 |
+
|
| 706 |
+
lightningcss-darwin-x64@1.32.0:
|
| 707 |
+
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
|
| 708 |
+
engines: {node: '>= 12.0.0'}
|
| 709 |
+
cpu: [x64]
|
| 710 |
+
os: [darwin]
|
| 711 |
+
|
| 712 |
+
lightningcss-freebsd-x64@1.32.0:
|
| 713 |
+
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
|
| 714 |
+
engines: {node: '>= 12.0.0'}
|
| 715 |
+
cpu: [x64]
|
| 716 |
+
os: [freebsd]
|
| 717 |
+
|
| 718 |
+
lightningcss-linux-arm-gnueabihf@1.32.0:
|
| 719 |
+
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
|
| 720 |
+
engines: {node: '>= 12.0.0'}
|
| 721 |
+
cpu: [arm]
|
| 722 |
+
os: [linux]
|
| 723 |
+
|
| 724 |
+
lightningcss-linux-arm64-gnu@1.32.0:
|
| 725 |
+
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
|
| 726 |
+
engines: {node: '>= 12.0.0'}
|
| 727 |
+
cpu: [arm64]
|
| 728 |
+
os: [linux]
|
| 729 |
+
libc: [glibc]
|
| 730 |
+
|
| 731 |
+
lightningcss-linux-arm64-musl@1.32.0:
|
| 732 |
+
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
| 733 |
+
engines: {node: '>= 12.0.0'}
|
| 734 |
+
cpu: [arm64]
|
| 735 |
+
os: [linux]
|
| 736 |
+
libc: [musl]
|
| 737 |
+
|
| 738 |
+
lightningcss-linux-x64-gnu@1.32.0:
|
| 739 |
+
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
| 740 |
+
engines: {node: '>= 12.0.0'}
|
| 741 |
+
cpu: [x64]
|
| 742 |
+
os: [linux]
|
| 743 |
+
libc: [glibc]
|
| 744 |
+
|
| 745 |
+
lightningcss-linux-x64-musl@1.32.0:
|
| 746 |
+
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
| 747 |
+
engines: {node: '>= 12.0.0'}
|
| 748 |
+
cpu: [x64]
|
| 749 |
+
os: [linux]
|
| 750 |
+
libc: [musl]
|
| 751 |
+
|
| 752 |
+
lightningcss-win32-arm64-msvc@1.32.0:
|
| 753 |
+
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
| 754 |
+
engines: {node: '>= 12.0.0'}
|
| 755 |
+
cpu: [arm64]
|
| 756 |
+
os: [win32]
|
| 757 |
+
|
| 758 |
+
lightningcss-win32-x64-msvc@1.32.0:
|
| 759 |
+
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
|
| 760 |
+
engines: {node: '>= 12.0.0'}
|
| 761 |
+
cpu: [x64]
|
| 762 |
+
os: [win32]
|
| 763 |
+
|
| 764 |
+
lightningcss@1.32.0:
|
| 765 |
+
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
| 766 |
+
engines: {node: '>= 12.0.0'}
|
| 767 |
+
|
| 768 |
+
locate-path@6.0.0:
|
| 769 |
+
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
| 770 |
+
engines: {node: '>=10'}
|
| 771 |
+
|
| 772 |
+
lodash.merge@4.6.2:
|
| 773 |
+
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
| 774 |
+
|
| 775 |
+
lru-cache@5.1.1:
|
| 776 |
+
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
| 777 |
+
|
| 778 |
+
minimatch@10.2.5:
|
| 779 |
+
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
| 780 |
+
engines: {node: 18 || 20 || >=22}
|
| 781 |
+
|
| 782 |
+
minimatch@3.1.5:
|
| 783 |
+
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
| 784 |
+
|
| 785 |
+
ms@2.1.3:
|
| 786 |
+
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
| 787 |
+
|
| 788 |
+
nanoid@3.3.11:
|
| 789 |
+
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
| 790 |
+
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
| 791 |
+
hasBin: true
|
| 792 |
+
|
| 793 |
+
natural-compare@1.4.0:
|
| 794 |
+
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
| 795 |
+
|
| 796 |
+
node-releases@2.0.37:
|
| 797 |
+
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
|
| 798 |
+
|
| 799 |
+
optionator@0.9.4:
|
| 800 |
+
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
| 801 |
+
engines: {node: '>= 0.8.0'}
|
| 802 |
+
|
| 803 |
+
p-limit@3.1.0:
|
| 804 |
+
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
| 805 |
+
engines: {node: '>=10'}
|
| 806 |
+
|
| 807 |
+
p-locate@5.0.0:
|
| 808 |
+
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
| 809 |
+
engines: {node: '>=10'}
|
| 810 |
+
|
| 811 |
+
parent-module@1.0.1:
|
| 812 |
+
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
| 813 |
+
engines: {node: '>=6'}
|
| 814 |
+
|
| 815 |
+
path-exists@4.0.0:
|
| 816 |
+
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
| 817 |
+
engines: {node: '>=8'}
|
| 818 |
+
|
| 819 |
+
path-key@3.1.1:
|
| 820 |
+
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
| 821 |
+
engines: {node: '>=8'}
|
| 822 |
+
|
| 823 |
+
picocolors@1.1.1:
|
| 824 |
+
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
| 825 |
+
|
| 826 |
+
picomatch@4.0.4:
|
| 827 |
+
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
| 828 |
+
engines: {node: '>=12'}
|
| 829 |
+
|
| 830 |
+
postcss@8.5.10:
|
| 831 |
+
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
| 832 |
+
engines: {node: ^10 || ^12 || >=14}
|
| 833 |
+
|
| 834 |
+
prelude-ls@1.2.1:
|
| 835 |
+
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
| 836 |
+
engines: {node: '>= 0.8.0'}
|
| 837 |
+
|
| 838 |
+
punycode@2.3.1:
|
| 839 |
+
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
| 840 |
+
engines: {node: '>=6'}
|
| 841 |
+
|
| 842 |
+
react-dom@19.2.5:
|
| 843 |
+
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
|
| 844 |
+
peerDependencies:
|
| 845 |
+
react: ^19.2.5
|
| 846 |
+
|
| 847 |
+
react@19.2.5:
|
| 848 |
+
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
|
| 849 |
+
engines: {node: '>=0.10.0'}
|
| 850 |
+
|
| 851 |
+
resolve-from@4.0.0:
|
| 852 |
+
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
| 853 |
+
engines: {node: '>=4'}
|
| 854 |
+
|
| 855 |
+
rolldown@1.0.0-rc.15:
|
| 856 |
+
resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==}
|
| 857 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 858 |
+
hasBin: true
|
| 859 |
+
|
| 860 |
+
scheduler@0.27.0:
|
| 861 |
+
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
| 862 |
+
|
| 863 |
+
semver@6.3.1:
|
| 864 |
+
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
| 865 |
+
hasBin: true
|
| 866 |
+
|
| 867 |
+
semver@7.7.4:
|
| 868 |
+
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
| 869 |
+
engines: {node: '>=10'}
|
| 870 |
+
hasBin: true
|
| 871 |
+
|
| 872 |
+
shebang-command@2.0.0:
|
| 873 |
+
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
| 874 |
+
engines: {node: '>=8'}
|
| 875 |
+
|
| 876 |
+
shebang-regex@3.0.0:
|
| 877 |
+
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
| 878 |
+
engines: {node: '>=8'}
|
| 879 |
+
|
| 880 |
+
source-map-js@1.2.1:
|
| 881 |
+
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
| 882 |
+
engines: {node: '>=0.10.0'}
|
| 883 |
+
|
| 884 |
+
strip-json-comments@3.1.1:
|
| 885 |
+
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
| 886 |
+
engines: {node: '>=8'}
|
| 887 |
+
|
| 888 |
+
supports-color@7.2.0:
|
| 889 |
+
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
| 890 |
+
engines: {node: '>=8'}
|
| 891 |
+
|
| 892 |
+
tinyglobby@0.2.16:
|
| 893 |
+
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
| 894 |
+
engines: {node: '>=12.0.0'}
|
| 895 |
+
|
| 896 |
+
ts-api-utils@2.5.0:
|
| 897 |
+
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
| 898 |
+
engines: {node: '>=18.12'}
|
| 899 |
+
peerDependencies:
|
| 900 |
+
typescript: '>=4.8.4'
|
| 901 |
+
|
| 902 |
+
tslib@2.8.1:
|
| 903 |
+
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
| 904 |
+
|
| 905 |
+
type-check@0.4.0:
|
| 906 |
+
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
| 907 |
+
engines: {node: '>= 0.8.0'}
|
| 908 |
+
|
| 909 |
+
typescript-eslint@8.58.2:
|
| 910 |
+
resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==}
|
| 911 |
+
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
| 912 |
+
peerDependencies:
|
| 913 |
+
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
| 914 |
+
typescript: '>=4.8.4 <6.1.0'
|
| 915 |
+
|
| 916 |
+
typescript@6.0.2:
|
| 917 |
+
resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==}
|
| 918 |
+
engines: {node: '>=14.17'}
|
| 919 |
+
hasBin: true
|
| 920 |
+
|
| 921 |
+
undici-types@7.16.0:
|
| 922 |
+
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
| 923 |
+
|
| 924 |
+
update-browserslist-db@1.2.3:
|
| 925 |
+
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
| 926 |
+
hasBin: true
|
| 927 |
+
peerDependencies:
|
| 928 |
+
browserslist: '>= 4.21.0'
|
| 929 |
+
|
| 930 |
+
uri-js@4.4.1:
|
| 931 |
+
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
| 932 |
+
|
| 933 |
+
vite@8.0.8:
|
| 934 |
+
resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
|
| 935 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 936 |
+
hasBin: true
|
| 937 |
+
peerDependencies:
|
| 938 |
+
'@types/node': ^20.19.0 || >=22.12.0
|
| 939 |
+
'@vitejs/devtools': ^0.1.0
|
| 940 |
+
esbuild: ^0.27.0 || ^0.28.0
|
| 941 |
+
jiti: '>=1.21.0'
|
| 942 |
+
less: ^4.0.0
|
| 943 |
+
sass: ^1.70.0
|
| 944 |
+
sass-embedded: ^1.70.0
|
| 945 |
+
stylus: '>=0.54.8'
|
| 946 |
+
sugarss: ^5.0.0
|
| 947 |
+
terser: ^5.16.0
|
| 948 |
+
tsx: ^4.8.1
|
| 949 |
+
yaml: ^2.4.2
|
| 950 |
+
peerDependenciesMeta:
|
| 951 |
+
'@types/node':
|
| 952 |
+
optional: true
|
| 953 |
+
'@vitejs/devtools':
|
| 954 |
+
optional: true
|
| 955 |
+
esbuild:
|
| 956 |
+
optional: true
|
| 957 |
+
jiti:
|
| 958 |
+
optional: true
|
| 959 |
+
less:
|
| 960 |
+
optional: true
|
| 961 |
+
sass:
|
| 962 |
+
optional: true
|
| 963 |
+
sass-embedded:
|
| 964 |
+
optional: true
|
| 965 |
+
stylus:
|
| 966 |
+
optional: true
|
| 967 |
+
sugarss:
|
| 968 |
+
optional: true
|
| 969 |
+
terser:
|
| 970 |
+
optional: true
|
| 971 |
+
tsx:
|
| 972 |
+
optional: true
|
| 973 |
+
yaml:
|
| 974 |
+
optional: true
|
| 975 |
+
|
| 976 |
+
which@2.0.2:
|
| 977 |
+
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
| 978 |
+
engines: {node: '>= 8'}
|
| 979 |
+
hasBin: true
|
| 980 |
+
|
| 981 |
+
word-wrap@1.2.5:
|
| 982 |
+
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
| 983 |
+
engines: {node: '>=0.10.0'}
|
| 984 |
+
|
| 985 |
+
yallist@3.1.1:
|
| 986 |
+
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
| 987 |
+
|
| 988 |
+
yocto-queue@0.1.0:
|
| 989 |
+
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
| 990 |
+
engines: {node: '>=10'}
|
| 991 |
+
|
| 992 |
+
zod-validation-error@4.0.2:
|
| 993 |
+
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
|
| 994 |
+
engines: {node: '>=18.0.0'}
|
| 995 |
+
peerDependencies:
|
| 996 |
+
zod: ^3.25.0 || ^4.0.0
|
| 997 |
+
|
| 998 |
+
zod@4.3.6:
|
| 999 |
+
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
| 1000 |
+
|
| 1001 |
+
snapshots:
|
| 1002 |
+
|
| 1003 |
+
'@babel/code-frame@7.29.0':
|
| 1004 |
+
dependencies:
|
| 1005 |
+
'@babel/helper-validator-identifier': 7.28.5
|
| 1006 |
+
js-tokens: 4.0.0
|
| 1007 |
+
picocolors: 1.1.1
|
| 1008 |
+
|
| 1009 |
+
'@babel/compat-data@7.29.0': {}
|
| 1010 |
+
|
| 1011 |
+
'@babel/core@7.29.0':
|
| 1012 |
+
dependencies:
|
| 1013 |
+
'@babel/code-frame': 7.29.0
|
| 1014 |
+
'@babel/generator': 7.29.1
|
| 1015 |
+
'@babel/helper-compilation-targets': 7.28.6
|
| 1016 |
+
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
| 1017 |
+
'@babel/helpers': 7.29.2
|
| 1018 |
+
'@babel/parser': 7.29.2
|
| 1019 |
+
'@babel/template': 7.28.6
|
| 1020 |
+
'@babel/traverse': 7.29.0
|
| 1021 |
+
'@babel/types': 7.29.0
|
| 1022 |
+
'@jridgewell/remapping': 2.3.5
|
| 1023 |
+
convert-source-map: 2.0.0
|
| 1024 |
+
debug: 4.4.3
|
| 1025 |
+
gensync: 1.0.0-beta.2
|
| 1026 |
+
json5: 2.2.3
|
| 1027 |
+
semver: 6.3.1
|
| 1028 |
+
transitivePeerDependencies:
|
| 1029 |
+
- supports-color
|
| 1030 |
+
|
| 1031 |
+
'@babel/generator@7.29.1':
|
| 1032 |
+
dependencies:
|
| 1033 |
+
'@babel/parser': 7.29.2
|
| 1034 |
+
'@babel/types': 7.29.0
|
| 1035 |
+
'@jridgewell/gen-mapping': 0.3.13
|
| 1036 |
+
'@jridgewell/trace-mapping': 0.3.31
|
| 1037 |
+
jsesc: 3.1.0
|
| 1038 |
+
|
| 1039 |
+
'@babel/helper-compilation-targets@7.28.6':
|
| 1040 |
+
dependencies:
|
| 1041 |
+
'@babel/compat-data': 7.29.0
|
| 1042 |
+
'@babel/helper-validator-option': 7.27.1
|
| 1043 |
+
browserslist: 4.28.2
|
| 1044 |
+
lru-cache: 5.1.1
|
| 1045 |
+
semver: 6.3.1
|
| 1046 |
+
|
| 1047 |
+
'@babel/helper-globals@7.28.0': {}
|
| 1048 |
+
|
| 1049 |
+
'@babel/helper-module-imports@7.28.6':
|
| 1050 |
+
dependencies:
|
| 1051 |
+
'@babel/traverse': 7.29.0
|
| 1052 |
+
'@babel/types': 7.29.0
|
| 1053 |
+
transitivePeerDependencies:
|
| 1054 |
+
- supports-color
|
| 1055 |
+
|
| 1056 |
+
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
|
| 1057 |
+
dependencies:
|
| 1058 |
+
'@babel/core': 7.29.0
|
| 1059 |
+
'@babel/helper-module-imports': 7.28.6
|
| 1060 |
+
'@babel/helper-validator-identifier': 7.28.5
|
| 1061 |
+
'@babel/traverse': 7.29.0
|
| 1062 |
+
transitivePeerDependencies:
|
| 1063 |
+
- supports-color
|
| 1064 |
+
|
| 1065 |
+
'@babel/helper-string-parser@7.27.1': {}
|
| 1066 |
+
|
| 1067 |
+
'@babel/helper-validator-identifier@7.28.5': {}
|
| 1068 |
+
|
| 1069 |
+
'@babel/helper-validator-option@7.27.1': {}
|
| 1070 |
+
|
| 1071 |
+
'@babel/helpers@7.29.2':
|
| 1072 |
+
dependencies:
|
| 1073 |
+
'@babel/template': 7.28.6
|
| 1074 |
+
'@babel/types': 7.29.0
|
| 1075 |
+
|
| 1076 |
+
'@babel/parser@7.29.2':
|
| 1077 |
+
dependencies:
|
| 1078 |
+
'@babel/types': 7.29.0
|
| 1079 |
+
|
| 1080 |
+
'@babel/template@7.28.6':
|
| 1081 |
+
dependencies:
|
| 1082 |
+
'@babel/code-frame': 7.29.0
|
| 1083 |
+
'@babel/parser': 7.29.2
|
| 1084 |
+
'@babel/types': 7.29.0
|
| 1085 |
+
|
| 1086 |
+
'@babel/traverse@7.29.0':
|
| 1087 |
+
dependencies:
|
| 1088 |
+
'@babel/code-frame': 7.29.0
|
| 1089 |
+
'@babel/generator': 7.29.1
|
| 1090 |
+
'@babel/helper-globals': 7.28.0
|
| 1091 |
+
'@babel/parser': 7.29.2
|
| 1092 |
+
'@babel/template': 7.28.6
|
| 1093 |
+
'@babel/types': 7.29.0
|
| 1094 |
+
debug: 4.4.3
|
| 1095 |
+
transitivePeerDependencies:
|
| 1096 |
+
- supports-color
|
| 1097 |
+
|
| 1098 |
+
'@babel/types@7.29.0':
|
| 1099 |
+
dependencies:
|
| 1100 |
+
'@babel/helper-string-parser': 7.27.1
|
| 1101 |
+
'@babel/helper-validator-identifier': 7.28.5
|
| 1102 |
+
|
| 1103 |
+
'@emnapi/core@1.9.2':
|
| 1104 |
+
dependencies:
|
| 1105 |
+
'@emnapi/wasi-threads': 1.2.1
|
| 1106 |
+
tslib: 2.8.1
|
| 1107 |
+
optional: true
|
| 1108 |
+
|
| 1109 |
+
'@emnapi/runtime@1.9.2':
|
| 1110 |
+
dependencies:
|
| 1111 |
+
tslib: 2.8.1
|
| 1112 |
+
optional: true
|
| 1113 |
+
|
| 1114 |
+
'@emnapi/wasi-threads@1.2.1':
|
| 1115 |
+
dependencies:
|
| 1116 |
+
tslib: 2.8.1
|
| 1117 |
+
optional: true
|
| 1118 |
+
|
| 1119 |
+
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
|
| 1120 |
+
dependencies:
|
| 1121 |
+
eslint: 9.39.4
|
| 1122 |
+
eslint-visitor-keys: 3.4.3
|
| 1123 |
+
|
| 1124 |
+
'@eslint-community/regexpp@4.12.2': {}
|
| 1125 |
+
|
| 1126 |
+
'@eslint/config-array@0.21.2':
|
| 1127 |
+
dependencies:
|
| 1128 |
+
'@eslint/object-schema': 2.1.7
|
| 1129 |
+
debug: 4.4.3
|
| 1130 |
+
minimatch: 3.1.5
|
| 1131 |
+
transitivePeerDependencies:
|
| 1132 |
+
- supports-color
|
| 1133 |
+
|
| 1134 |
+
'@eslint/config-helpers@0.4.2':
|
| 1135 |
+
dependencies:
|
| 1136 |
+
'@eslint/core': 0.17.0
|
| 1137 |
+
|
| 1138 |
+
'@eslint/core@0.17.0':
|
| 1139 |
+
dependencies:
|
| 1140 |
+
'@types/json-schema': 7.0.15
|
| 1141 |
+
|
| 1142 |
+
'@eslint/eslintrc@3.3.5':
|
| 1143 |
+
dependencies:
|
| 1144 |
+
ajv: 6.14.0
|
| 1145 |
+
debug: 4.4.3
|
| 1146 |
+
espree: 10.4.0
|
| 1147 |
+
globals: 14.0.0
|
| 1148 |
+
ignore: 5.3.2
|
| 1149 |
+
import-fresh: 3.3.1
|
| 1150 |
+
js-yaml: 4.1.1
|
| 1151 |
+
minimatch: 3.1.5
|
| 1152 |
+
strip-json-comments: 3.1.1
|
| 1153 |
+
transitivePeerDependencies:
|
| 1154 |
+
- supports-color
|
| 1155 |
+
|
| 1156 |
+
'@eslint/js@9.39.4': {}
|
| 1157 |
+
|
| 1158 |
+
'@eslint/object-schema@2.1.7': {}
|
| 1159 |
+
|
| 1160 |
+
'@eslint/plugin-kit@0.4.1':
|
| 1161 |
+
dependencies:
|
| 1162 |
+
'@eslint/core': 0.17.0
|
| 1163 |
+
levn: 0.4.1
|
| 1164 |
+
|
| 1165 |
+
'@humanfs/core@0.19.1': {}
|
| 1166 |
+
|
| 1167 |
+
'@humanfs/node@0.16.7':
|
| 1168 |
+
dependencies:
|
| 1169 |
+
'@humanfs/core': 0.19.1
|
| 1170 |
+
'@humanwhocodes/retry': 0.4.3
|
| 1171 |
+
|
| 1172 |
+
'@humanwhocodes/module-importer@1.0.1': {}
|
| 1173 |
+
|
| 1174 |
+
'@humanwhocodes/retry@0.4.3': {}
|
| 1175 |
+
|
| 1176 |
+
'@jridgewell/gen-mapping@0.3.13':
|
| 1177 |
+
dependencies:
|
| 1178 |
+
'@jridgewell/sourcemap-codec': 1.5.5
|
| 1179 |
+
'@jridgewell/trace-mapping': 0.3.31
|
| 1180 |
+
|
| 1181 |
+
'@jridgewell/remapping@2.3.5':
|
| 1182 |
+
dependencies:
|
| 1183 |
+
'@jridgewell/gen-mapping': 0.3.13
|
| 1184 |
+
'@jridgewell/trace-mapping': 0.3.31
|
| 1185 |
+
|
| 1186 |
+
'@jridgewell/resolve-uri@3.1.2': {}
|
| 1187 |
+
|
| 1188 |
+
'@jridgewell/sourcemap-codec@1.5.5': {}
|
| 1189 |
+
|
| 1190 |
+
'@jridgewell/trace-mapping@0.3.31':
|
| 1191 |
+
dependencies:
|
| 1192 |
+
'@jridgewell/resolve-uri': 3.1.2
|
| 1193 |
+
'@jridgewell/sourcemap-codec': 1.5.5
|
| 1194 |
+
|
| 1195 |
+
'@mediapipe/tasks-vision@0.10.34': {}
|
| 1196 |
+
|
| 1197 |
+
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
|
| 1198 |
+
dependencies:
|
| 1199 |
+
'@emnapi/core': 1.9.2
|
| 1200 |
+
'@emnapi/runtime': 1.9.2
|
| 1201 |
+
'@tybys/wasm-util': 0.10.1
|
| 1202 |
+
optional: true
|
| 1203 |
+
|
| 1204 |
+
'@oxc-project/types@0.124.0': {}
|
| 1205 |
+
|
| 1206 |
+
'@rolldown/binding-android-arm64@1.0.0-rc.15':
|
| 1207 |
+
optional: true
|
| 1208 |
+
|
| 1209 |
+
'@rolldown/binding-darwin-arm64@1.0.0-rc.15':
|
| 1210 |
+
optional: true
|
| 1211 |
+
|
| 1212 |
+
'@rolldown/binding-darwin-x64@1.0.0-rc.15':
|
| 1213 |
+
optional: true
|
| 1214 |
+
|
| 1215 |
+
'@rolldown/binding-freebsd-x64@1.0.0-rc.15':
|
| 1216 |
+
optional: true
|
| 1217 |
+
|
| 1218 |
+
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15':
|
| 1219 |
+
optional: true
|
| 1220 |
+
|
| 1221 |
+
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15':
|
| 1222 |
+
optional: true
|
| 1223 |
+
|
| 1224 |
+
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
|
| 1225 |
+
optional: true
|
| 1226 |
+
|
| 1227 |
+
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
|
| 1228 |
+
optional: true
|
| 1229 |
+
|
| 1230 |
+
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
|
| 1231 |
+
optional: true
|
| 1232 |
+
|
| 1233 |
+
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
|
| 1234 |
+
optional: true
|
| 1235 |
+
|
| 1236 |
+
'@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
|
| 1237 |
+
optional: true
|
| 1238 |
+
|
| 1239 |
+
'@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
|
| 1240 |
+
optional: true
|
| 1241 |
+
|
| 1242 |
+
'@rolldown/binding-wasm32-wasi@1.0.0-rc.15':
|
| 1243 |
+
dependencies:
|
| 1244 |
+
'@emnapi/core': 1.9.2
|
| 1245 |
+
'@emnapi/runtime': 1.9.2
|
| 1246 |
+
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
| 1247 |
+
optional: true
|
| 1248 |
+
|
| 1249 |
+
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
|
| 1250 |
+
optional: true
|
| 1251 |
+
|
| 1252 |
+
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.15':
|
| 1253 |
+
optional: true
|
| 1254 |
+
|
| 1255 |
+
'@rolldown/pluginutils@1.0.0-rc.15': {}
|
| 1256 |
+
|
| 1257 |
+
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
| 1258 |
+
|
| 1259 |
+
'@tybys/wasm-util@0.10.1':
|
| 1260 |
+
dependencies:
|
| 1261 |
+
tslib: 2.8.1
|
| 1262 |
+
optional: true
|
| 1263 |
+
|
| 1264 |
+
'@types/estree@1.0.8': {}
|
| 1265 |
+
|
| 1266 |
+
'@types/json-schema@7.0.15': {}
|
| 1267 |
+
|
| 1268 |
+
'@types/node@24.12.2':
|
| 1269 |
+
dependencies:
|
| 1270 |
+
undici-types: 7.16.0
|
| 1271 |
+
|
| 1272 |
+
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
| 1273 |
+
dependencies:
|
| 1274 |
+
'@types/react': 19.2.14
|
| 1275 |
+
|
| 1276 |
+
'@types/react@19.2.14':
|
| 1277 |
+
dependencies:
|
| 1278 |
+
csstype: 3.2.3
|
| 1279 |
+
|
| 1280 |
+
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@6.0.2))(eslint@9.39.4)(typescript@6.0.2)':
|
| 1281 |
+
dependencies:
|
| 1282 |
+
'@eslint-community/regexpp': 4.12.2
|
| 1283 |
+
'@typescript-eslint/parser': 8.58.2(eslint@9.39.4)(typescript@6.0.2)
|
| 1284 |
+
'@typescript-eslint/scope-manager': 8.58.2
|
| 1285 |
+
'@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4)(typescript@6.0.2)
|
| 1286 |
+
'@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@6.0.2)
|
| 1287 |
+
'@typescript-eslint/visitor-keys': 8.58.2
|
| 1288 |
+
eslint: 9.39.4
|
| 1289 |
+
ignore: 7.0.5
|
| 1290 |
+
natural-compare: 1.4.0
|
| 1291 |
+
ts-api-utils: 2.5.0(typescript@6.0.2)
|
| 1292 |
+
typescript: 6.0.2
|
| 1293 |
+
transitivePeerDependencies:
|
| 1294 |
+
- supports-color
|
| 1295 |
+
|
| 1296 |
+
'@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@6.0.2)':
|
| 1297 |
+
dependencies:
|
| 1298 |
+
'@typescript-eslint/scope-manager': 8.58.2
|
| 1299 |
+
'@typescript-eslint/types': 8.58.2
|
| 1300 |
+
'@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2)
|
| 1301 |
+
'@typescript-eslint/visitor-keys': 8.58.2
|
| 1302 |
+
debug: 4.4.3
|
| 1303 |
+
eslint: 9.39.4
|
| 1304 |
+
typescript: 6.0.2
|
| 1305 |
+
transitivePeerDependencies:
|
| 1306 |
+
- supports-color
|
| 1307 |
+
|
| 1308 |
+
'@typescript-eslint/project-service@8.58.2(typescript@6.0.2)':
|
| 1309 |
+
dependencies:
|
| 1310 |
+
'@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.2)
|
| 1311 |
+
'@typescript-eslint/types': 8.58.2
|
| 1312 |
+
debug: 4.4.3
|
| 1313 |
+
typescript: 6.0.2
|
| 1314 |
+
transitivePeerDependencies:
|
| 1315 |
+
- supports-color
|
| 1316 |
+
|
| 1317 |
+
'@typescript-eslint/scope-manager@8.58.2':
|
| 1318 |
+
dependencies:
|
| 1319 |
+
'@typescript-eslint/types': 8.58.2
|
| 1320 |
+
'@typescript-eslint/visitor-keys': 8.58.2
|
| 1321 |
+
|
| 1322 |
+
'@typescript-eslint/tsconfig-utils@8.58.2(typescript@6.0.2)':
|
| 1323 |
+
dependencies:
|
| 1324 |
+
typescript: 6.0.2
|
| 1325 |
+
|
| 1326 |
+
'@typescript-eslint/type-utils@8.58.2(eslint@9.39.4)(typescript@6.0.2)':
|
| 1327 |
+
dependencies:
|
| 1328 |
+
'@typescript-eslint/types': 8.58.2
|
| 1329 |
+
'@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2)
|
| 1330 |
+
'@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@6.0.2)
|
| 1331 |
+
debug: 4.4.3
|
| 1332 |
+
eslint: 9.39.4
|
| 1333 |
+
ts-api-utils: 2.5.0(typescript@6.0.2)
|
| 1334 |
+
typescript: 6.0.2
|
| 1335 |
+
transitivePeerDependencies:
|
| 1336 |
+
- supports-color
|
| 1337 |
+
|
| 1338 |
+
'@typescript-eslint/types@8.58.2': {}
|
| 1339 |
+
|
| 1340 |
+
'@typescript-eslint/typescript-estree@8.58.2(typescript@6.0.2)':
|
| 1341 |
+
dependencies:
|
| 1342 |
+
'@typescript-eslint/project-service': 8.58.2(typescript@6.0.2)
|
| 1343 |
+
'@typescript-eslint/tsconfig-utils': 8.58.2(typescript@6.0.2)
|
| 1344 |
+
'@typescript-eslint/types': 8.58.2
|
| 1345 |
+
'@typescript-eslint/visitor-keys': 8.58.2
|
| 1346 |
+
debug: 4.4.3
|
| 1347 |
+
minimatch: 10.2.5
|
| 1348 |
+
semver: 7.7.4
|
| 1349 |
+
tinyglobby: 0.2.16
|
| 1350 |
+
ts-api-utils: 2.5.0(typescript@6.0.2)
|
| 1351 |
+
typescript: 6.0.2
|
| 1352 |
+
transitivePeerDependencies:
|
| 1353 |
+
- supports-color
|
| 1354 |
+
|
| 1355 |
+
'@typescript-eslint/utils@8.58.2(eslint@9.39.4)(typescript@6.0.2)':
|
| 1356 |
+
dependencies:
|
| 1357 |
+
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
| 1358 |
+
'@typescript-eslint/scope-manager': 8.58.2
|
| 1359 |
+
'@typescript-eslint/types': 8.58.2
|
| 1360 |
+
'@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2)
|
| 1361 |
+
eslint: 9.39.4
|
| 1362 |
+
typescript: 6.0.2
|
| 1363 |
+
transitivePeerDependencies:
|
| 1364 |
+
- supports-color
|
| 1365 |
+
|
| 1366 |
+
'@typescript-eslint/visitor-keys@8.58.2':
|
| 1367 |
+
dependencies:
|
| 1368 |
+
'@typescript-eslint/types': 8.58.2
|
| 1369 |
+
eslint-visitor-keys: 5.0.1
|
| 1370 |
+
|
| 1371 |
+
'@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@24.12.2))':
|
| 1372 |
+
dependencies:
|
| 1373 |
+
'@rolldown/pluginutils': 1.0.0-rc.7
|
| 1374 |
+
vite: 8.0.8(@types/node@24.12.2)
|
| 1375 |
+
|
| 1376 |
+
acorn-jsx@5.3.2(acorn@8.16.0):
|
| 1377 |
+
dependencies:
|
| 1378 |
+
acorn: 8.16.0
|
| 1379 |
+
|
| 1380 |
+
acorn@8.16.0: {}
|
| 1381 |
+
|
| 1382 |
+
ajv@6.14.0:
|
| 1383 |
+
dependencies:
|
| 1384 |
+
fast-deep-equal: 3.1.3
|
| 1385 |
+
fast-json-stable-stringify: 2.1.0
|
| 1386 |
+
json-schema-traverse: 0.4.1
|
| 1387 |
+
uri-js: 4.4.1
|
| 1388 |
+
|
| 1389 |
+
ansi-styles@4.3.0:
|
| 1390 |
+
dependencies:
|
| 1391 |
+
color-convert: 2.0.1
|
| 1392 |
+
|
| 1393 |
+
argparse@2.0.1: {}
|
| 1394 |
+
|
| 1395 |
+
balanced-match@1.0.2: {}
|
| 1396 |
+
|
| 1397 |
+
balanced-match@4.0.4: {}
|
| 1398 |
+
|
| 1399 |
+
baseline-browser-mapping@2.10.19: {}
|
| 1400 |
+
|
| 1401 |
+
brace-expansion@1.1.14:
|
| 1402 |
+
dependencies:
|
| 1403 |
+
balanced-match: 1.0.2
|
| 1404 |
+
concat-map: 0.0.1
|
| 1405 |
+
|
| 1406 |
+
brace-expansion@5.0.5:
|
| 1407 |
+
dependencies:
|
| 1408 |
+
balanced-match: 4.0.4
|
| 1409 |
+
|
| 1410 |
+
browserslist@4.28.2:
|
| 1411 |
+
dependencies:
|
| 1412 |
+
baseline-browser-mapping: 2.10.19
|
| 1413 |
+
caniuse-lite: 1.0.30001788
|
| 1414 |
+
electron-to-chromium: 1.5.337
|
| 1415 |
+
node-releases: 2.0.37
|
| 1416 |
+
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
| 1417 |
+
|
| 1418 |
+
callsites@3.1.0: {}
|
| 1419 |
+
|
| 1420 |
+
caniuse-lite@1.0.30001788: {}
|
| 1421 |
+
|
| 1422 |
+
chalk@4.1.2:
|
| 1423 |
+
dependencies:
|
| 1424 |
+
ansi-styles: 4.3.0
|
| 1425 |
+
supports-color: 7.2.0
|
| 1426 |
+
|
| 1427 |
+
color-convert@2.0.1:
|
| 1428 |
+
dependencies:
|
| 1429 |
+
color-name: 1.1.4
|
| 1430 |
+
|
| 1431 |
+
color-name@1.1.4: {}
|
| 1432 |
+
|
| 1433 |
+
concat-map@0.0.1: {}
|
| 1434 |
+
|
| 1435 |
+
convert-source-map@2.0.0: {}
|
| 1436 |
+
|
| 1437 |
+
cross-spawn@7.0.6:
|
| 1438 |
+
dependencies:
|
| 1439 |
+
path-key: 3.1.1
|
| 1440 |
+
shebang-command: 2.0.0
|
| 1441 |
+
which: 2.0.2
|
| 1442 |
+
|
| 1443 |
+
csstype@3.2.3: {}
|
| 1444 |
+
|
| 1445 |
+
debug@4.4.3:
|
| 1446 |
+
dependencies:
|
| 1447 |
+
ms: 2.1.3
|
| 1448 |
+
|
| 1449 |
+
deep-is@0.1.4: {}
|
| 1450 |
+
|
| 1451 |
+
detect-libc@2.1.2: {}
|
| 1452 |
+
|
| 1453 |
+
electron-to-chromium@1.5.337: {}
|
| 1454 |
+
|
| 1455 |
+
escalade@3.2.0: {}
|
| 1456 |
+
|
| 1457 |
+
escape-string-regexp@4.0.0: {}
|
| 1458 |
+
|
| 1459 |
+
eslint-plugin-react-hooks@7.0.1(eslint@9.39.4):
|
| 1460 |
+
dependencies:
|
| 1461 |
+
'@babel/core': 7.29.0
|
| 1462 |
+
'@babel/parser': 7.29.2
|
| 1463 |
+
eslint: 9.39.4
|
| 1464 |
+
hermes-parser: 0.25.1
|
| 1465 |
+
zod: 4.3.6
|
| 1466 |
+
zod-validation-error: 4.0.2(zod@4.3.6)
|
| 1467 |
+
transitivePeerDependencies:
|
| 1468 |
+
- supports-color
|
| 1469 |
+
|
| 1470 |
+
eslint-plugin-react-refresh@0.5.2(eslint@9.39.4):
|
| 1471 |
+
dependencies:
|
| 1472 |
+
eslint: 9.39.4
|
| 1473 |
+
|
| 1474 |
+
eslint-scope@8.4.0:
|
| 1475 |
+
dependencies:
|
| 1476 |
+
esrecurse: 4.3.0
|
| 1477 |
+
estraverse: 5.3.0
|
| 1478 |
+
|
| 1479 |
+
eslint-visitor-keys@3.4.3: {}
|
| 1480 |
+
|
| 1481 |
+
eslint-visitor-keys@4.2.1: {}
|
| 1482 |
+
|
| 1483 |
+
eslint-visitor-keys@5.0.1: {}
|
| 1484 |
+
|
| 1485 |
+
eslint@9.39.4:
|
| 1486 |
+
dependencies:
|
| 1487 |
+
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
| 1488 |
+
'@eslint-community/regexpp': 4.12.2
|
| 1489 |
+
'@eslint/config-array': 0.21.2
|
| 1490 |
+
'@eslint/config-helpers': 0.4.2
|
| 1491 |
+
'@eslint/core': 0.17.0
|
| 1492 |
+
'@eslint/eslintrc': 3.3.5
|
| 1493 |
+
'@eslint/js': 9.39.4
|
| 1494 |
+
'@eslint/plugin-kit': 0.4.1
|
| 1495 |
+
'@humanfs/node': 0.16.7
|
| 1496 |
+
'@humanwhocodes/module-importer': 1.0.1
|
| 1497 |
+
'@humanwhocodes/retry': 0.4.3
|
| 1498 |
+
'@types/estree': 1.0.8
|
| 1499 |
+
ajv: 6.14.0
|
| 1500 |
+
chalk: 4.1.2
|
| 1501 |
+
cross-spawn: 7.0.6
|
| 1502 |
+
debug: 4.4.3
|
| 1503 |
+
escape-string-regexp: 4.0.0
|
| 1504 |
+
eslint-scope: 8.4.0
|
| 1505 |
+
eslint-visitor-keys: 4.2.1
|
| 1506 |
+
espree: 10.4.0
|
| 1507 |
+
esquery: 1.7.0
|
| 1508 |
+
esutils: 2.0.3
|
| 1509 |
+
fast-deep-equal: 3.1.3
|
| 1510 |
+
file-entry-cache: 8.0.0
|
| 1511 |
+
find-up: 5.0.0
|
| 1512 |
+
glob-parent: 6.0.2
|
| 1513 |
+
ignore: 5.3.2
|
| 1514 |
+
imurmurhash: 0.1.4
|
| 1515 |
+
is-glob: 4.0.3
|
| 1516 |
+
json-stable-stringify-without-jsonify: 1.0.1
|
| 1517 |
+
lodash.merge: 4.6.2
|
| 1518 |
+
minimatch: 3.1.5
|
| 1519 |
+
natural-compare: 1.4.0
|
| 1520 |
+
optionator: 0.9.4
|
| 1521 |
+
transitivePeerDependencies:
|
| 1522 |
+
- supports-color
|
| 1523 |
+
|
| 1524 |
+
espree@10.4.0:
|
| 1525 |
+
dependencies:
|
| 1526 |
+
acorn: 8.16.0
|
| 1527 |
+
acorn-jsx: 5.3.2(acorn@8.16.0)
|
| 1528 |
+
eslint-visitor-keys: 4.2.1
|
| 1529 |
+
|
| 1530 |
+
esquery@1.7.0:
|
| 1531 |
+
dependencies:
|
| 1532 |
+
estraverse: 5.3.0
|
| 1533 |
+
|
| 1534 |
+
esrecurse@4.3.0:
|
| 1535 |
+
dependencies:
|
| 1536 |
+
estraverse: 5.3.0
|
| 1537 |
+
|
| 1538 |
+
estraverse@5.3.0: {}
|
| 1539 |
+
|
| 1540 |
+
esutils@2.0.3: {}
|
| 1541 |
+
|
| 1542 |
+
fast-deep-equal@3.1.3: {}
|
| 1543 |
+
|
| 1544 |
+
fast-json-stable-stringify@2.1.0: {}
|
| 1545 |
+
|
| 1546 |
+
fast-levenshtein@2.0.6: {}
|
| 1547 |
+
|
| 1548 |
+
fdir@6.5.0(picomatch@4.0.4):
|
| 1549 |
+
optionalDependencies:
|
| 1550 |
+
picomatch: 4.0.4
|
| 1551 |
+
|
| 1552 |
+
file-entry-cache@8.0.0:
|
| 1553 |
+
dependencies:
|
| 1554 |
+
flat-cache: 4.0.1
|
| 1555 |
+
|
| 1556 |
+
find-up@5.0.0:
|
| 1557 |
+
dependencies:
|
| 1558 |
+
locate-path: 6.0.0
|
| 1559 |
+
path-exists: 4.0.0
|
| 1560 |
+
|
| 1561 |
+
flat-cache@4.0.1:
|
| 1562 |
+
dependencies:
|
| 1563 |
+
flatted: 3.4.2
|
| 1564 |
+
keyv: 4.5.4
|
| 1565 |
+
|
| 1566 |
+
flatted@3.4.2: {}
|
| 1567 |
+
|
| 1568 |
+
fsevents@2.3.3:
|
| 1569 |
+
optional: true
|
| 1570 |
+
|
| 1571 |
+
gensync@1.0.0-beta.2: {}
|
| 1572 |
+
|
| 1573 |
+
glob-parent@6.0.2:
|
| 1574 |
+
dependencies:
|
| 1575 |
+
is-glob: 4.0.3
|
| 1576 |
+
|
| 1577 |
+
globals@14.0.0: {}
|
| 1578 |
+
|
| 1579 |
+
globals@17.5.0: {}
|
| 1580 |
+
|
| 1581 |
+
has-flag@4.0.0: {}
|
| 1582 |
+
|
| 1583 |
+
hermes-estree@0.25.1: {}
|
| 1584 |
+
|
| 1585 |
+
hermes-parser@0.25.1:
|
| 1586 |
+
dependencies:
|
| 1587 |
+
hermes-estree: 0.25.1
|
| 1588 |
+
|
| 1589 |
+
ignore@5.3.2: {}
|
| 1590 |
+
|
| 1591 |
+
ignore@7.0.5: {}
|
| 1592 |
+
|
| 1593 |
+
import-fresh@3.3.1:
|
| 1594 |
+
dependencies:
|
| 1595 |
+
parent-module: 1.0.1
|
| 1596 |
+
resolve-from: 4.0.0
|
| 1597 |
+
|
| 1598 |
+
imurmurhash@0.1.4: {}
|
| 1599 |
+
|
| 1600 |
+
is-extglob@2.1.1: {}
|
| 1601 |
+
|
| 1602 |
+
is-glob@4.0.3:
|
| 1603 |
+
dependencies:
|
| 1604 |
+
is-extglob: 2.1.1
|
| 1605 |
+
|
| 1606 |
+
isexe@2.0.0: {}
|
| 1607 |
+
|
| 1608 |
+
js-tokens@4.0.0: {}
|
| 1609 |
+
|
| 1610 |
+
js-yaml@4.1.1:
|
| 1611 |
+
dependencies:
|
| 1612 |
+
argparse: 2.0.1
|
| 1613 |
+
|
| 1614 |
+
jsesc@3.1.0: {}
|
| 1615 |
+
|
| 1616 |
+
json-buffer@3.0.1: {}
|
| 1617 |
+
|
| 1618 |
+
json-schema-traverse@0.4.1: {}
|
| 1619 |
+
|
| 1620 |
+
json-stable-stringify-without-jsonify@1.0.1: {}
|
| 1621 |
+
|
| 1622 |
+
json5@2.2.3: {}
|
| 1623 |
+
|
| 1624 |
+
keyv@4.5.4:
|
| 1625 |
+
dependencies:
|
| 1626 |
+
json-buffer: 3.0.1
|
| 1627 |
+
|
| 1628 |
+
levn@0.4.1:
|
| 1629 |
+
dependencies:
|
| 1630 |
+
prelude-ls: 1.2.1
|
| 1631 |
+
type-check: 0.4.0
|
| 1632 |
+
|
| 1633 |
+
lightningcss-android-arm64@1.32.0:
|
| 1634 |
+
optional: true
|
| 1635 |
+
|
| 1636 |
+
lightningcss-darwin-arm64@1.32.0:
|
| 1637 |
+
optional: true
|
| 1638 |
+
|
| 1639 |
+
lightningcss-darwin-x64@1.32.0:
|
| 1640 |
+
optional: true
|
| 1641 |
+
|
| 1642 |
+
lightningcss-freebsd-x64@1.32.0:
|
| 1643 |
+
optional: true
|
| 1644 |
+
|
| 1645 |
+
lightningcss-linux-arm-gnueabihf@1.32.0:
|
| 1646 |
+
optional: true
|
| 1647 |
+
|
| 1648 |
+
lightningcss-linux-arm64-gnu@1.32.0:
|
| 1649 |
+
optional: true
|
| 1650 |
+
|
| 1651 |
+
lightningcss-linux-arm64-musl@1.32.0:
|
| 1652 |
+
optional: true
|
| 1653 |
+
|
| 1654 |
+
lightningcss-linux-x64-gnu@1.32.0:
|
| 1655 |
+
optional: true
|
| 1656 |
+
|
| 1657 |
+
lightningcss-linux-x64-musl@1.32.0:
|
| 1658 |
+
optional: true
|
| 1659 |
+
|
| 1660 |
+
lightningcss-win32-arm64-msvc@1.32.0:
|
| 1661 |
+
optional: true
|
| 1662 |
+
|
| 1663 |
+
lightningcss-win32-x64-msvc@1.32.0:
|
| 1664 |
+
optional: true
|
| 1665 |
+
|
| 1666 |
+
lightningcss@1.32.0:
|
| 1667 |
+
dependencies:
|
| 1668 |
+
detect-libc: 2.1.2
|
| 1669 |
+
optionalDependencies:
|
| 1670 |
+
lightningcss-android-arm64: 1.32.0
|
| 1671 |
+
lightningcss-darwin-arm64: 1.32.0
|
| 1672 |
+
lightningcss-darwin-x64: 1.32.0
|
| 1673 |
+
lightningcss-freebsd-x64: 1.32.0
|
| 1674 |
+
lightningcss-linux-arm-gnueabihf: 1.32.0
|
| 1675 |
+
lightningcss-linux-arm64-gnu: 1.32.0
|
| 1676 |
+
lightningcss-linux-arm64-musl: 1.32.0
|
| 1677 |
+
lightningcss-linux-x64-gnu: 1.32.0
|
| 1678 |
+
lightningcss-linux-x64-musl: 1.32.0
|
| 1679 |
+
lightningcss-win32-arm64-msvc: 1.32.0
|
| 1680 |
+
lightningcss-win32-x64-msvc: 1.32.0
|
| 1681 |
+
|
| 1682 |
+
locate-path@6.0.0:
|
| 1683 |
+
dependencies:
|
| 1684 |
+
p-locate: 5.0.0
|
| 1685 |
+
|
| 1686 |
+
lodash.merge@4.6.2: {}
|
| 1687 |
+
|
| 1688 |
+
lru-cache@5.1.1:
|
| 1689 |
+
dependencies:
|
| 1690 |
+
yallist: 3.1.1
|
| 1691 |
+
|
| 1692 |
+
minimatch@10.2.5:
|
| 1693 |
+
dependencies:
|
| 1694 |
+
brace-expansion: 5.0.5
|
| 1695 |
+
|
| 1696 |
+
minimatch@3.1.5:
|
| 1697 |
+
dependencies:
|
| 1698 |
+
brace-expansion: 1.1.14
|
| 1699 |
+
|
| 1700 |
+
ms@2.1.3: {}
|
| 1701 |
+
|
| 1702 |
+
nanoid@3.3.11: {}
|
| 1703 |
+
|
| 1704 |
+
natural-compare@1.4.0: {}
|
| 1705 |
+
|
| 1706 |
+
node-releases@2.0.37: {}
|
| 1707 |
+
|
| 1708 |
+
optionator@0.9.4:
|
| 1709 |
+
dependencies:
|
| 1710 |
+
deep-is: 0.1.4
|
| 1711 |
+
fast-levenshtein: 2.0.6
|
| 1712 |
+
levn: 0.4.1
|
| 1713 |
+
prelude-ls: 1.2.1
|
| 1714 |
+
type-check: 0.4.0
|
| 1715 |
+
word-wrap: 1.2.5
|
| 1716 |
+
|
| 1717 |
+
p-limit@3.1.0:
|
| 1718 |
+
dependencies:
|
| 1719 |
+
yocto-queue: 0.1.0
|
| 1720 |
+
|
| 1721 |
+
p-locate@5.0.0:
|
| 1722 |
+
dependencies:
|
| 1723 |
+
p-limit: 3.1.0
|
| 1724 |
+
|
| 1725 |
+
parent-module@1.0.1:
|
| 1726 |
+
dependencies:
|
| 1727 |
+
callsites: 3.1.0
|
| 1728 |
+
|
| 1729 |
+
path-exists@4.0.0: {}
|
| 1730 |
+
|
| 1731 |
+
path-key@3.1.1: {}
|
| 1732 |
+
|
| 1733 |
+
picocolors@1.1.1: {}
|
| 1734 |
+
|
| 1735 |
+
picomatch@4.0.4: {}
|
| 1736 |
+
|
| 1737 |
+
postcss@8.5.10:
|
| 1738 |
+
dependencies:
|
| 1739 |
+
nanoid: 3.3.11
|
| 1740 |
+
picocolors: 1.1.1
|
| 1741 |
+
source-map-js: 1.2.1
|
| 1742 |
+
|
| 1743 |
+
prelude-ls@1.2.1: {}
|
| 1744 |
+
|
| 1745 |
+
punycode@2.3.1: {}
|
| 1746 |
+
|
| 1747 |
+
react-dom@19.2.5(react@19.2.5):
|
| 1748 |
+
dependencies:
|
| 1749 |
+
react: 19.2.5
|
| 1750 |
+
scheduler: 0.27.0
|
| 1751 |
+
|
| 1752 |
+
react@19.2.5: {}
|
| 1753 |
+
|
| 1754 |
+
resolve-from@4.0.0: {}
|
| 1755 |
+
|
| 1756 |
+
rolldown@1.0.0-rc.15:
|
| 1757 |
+
dependencies:
|
| 1758 |
+
'@oxc-project/types': 0.124.0
|
| 1759 |
+
'@rolldown/pluginutils': 1.0.0-rc.15
|
| 1760 |
+
optionalDependencies:
|
| 1761 |
+
'@rolldown/binding-android-arm64': 1.0.0-rc.15
|
| 1762 |
+
'@rolldown/binding-darwin-arm64': 1.0.0-rc.15
|
| 1763 |
+
'@rolldown/binding-darwin-x64': 1.0.0-rc.15
|
| 1764 |
+
'@rolldown/binding-freebsd-x64': 1.0.0-rc.15
|
| 1765 |
+
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15
|
| 1766 |
+
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15
|
| 1767 |
+
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15
|
| 1768 |
+
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15
|
| 1769 |
+
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15
|
| 1770 |
+
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15
|
| 1771 |
+
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.15
|
| 1772 |
+
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.15
|
| 1773 |
+
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.15
|
| 1774 |
+
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15
|
| 1775 |
+
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15
|
| 1776 |
+
|
| 1777 |
+
scheduler@0.27.0: {}
|
| 1778 |
+
|
| 1779 |
+
semver@6.3.1: {}
|
| 1780 |
+
|
| 1781 |
+
semver@7.7.4: {}
|
| 1782 |
+
|
| 1783 |
+
shebang-command@2.0.0:
|
| 1784 |
+
dependencies:
|
| 1785 |
+
shebang-regex: 3.0.0
|
| 1786 |
+
|
| 1787 |
+
shebang-regex@3.0.0: {}
|
| 1788 |
+
|
| 1789 |
+
source-map-js@1.2.1: {}
|
| 1790 |
+
|
| 1791 |
+
strip-json-comments@3.1.1: {}
|
| 1792 |
+
|
| 1793 |
+
supports-color@7.2.0:
|
| 1794 |
+
dependencies:
|
| 1795 |
+
has-flag: 4.0.0
|
| 1796 |
+
|
| 1797 |
+
tinyglobby@0.2.16:
|
| 1798 |
+
dependencies:
|
| 1799 |
+
fdir: 6.5.0(picomatch@4.0.4)
|
| 1800 |
+
picomatch: 4.0.4
|
| 1801 |
+
|
| 1802 |
+
ts-api-utils@2.5.0(typescript@6.0.2):
|
| 1803 |
+
dependencies:
|
| 1804 |
+
typescript: 6.0.2
|
| 1805 |
+
|
| 1806 |
+
tslib@2.8.1:
|
| 1807 |
+
optional: true
|
| 1808 |
+
|
| 1809 |
+
type-check@0.4.0:
|
| 1810 |
+
dependencies:
|
| 1811 |
+
prelude-ls: 1.2.1
|
| 1812 |
+
|
| 1813 |
+
typescript-eslint@8.58.2(eslint@9.39.4)(typescript@6.0.2):
|
| 1814 |
+
dependencies:
|
| 1815 |
+
'@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@6.0.2))(eslint@9.39.4)(typescript@6.0.2)
|
| 1816 |
+
'@typescript-eslint/parser': 8.58.2(eslint@9.39.4)(typescript@6.0.2)
|
| 1817 |
+
'@typescript-eslint/typescript-estree': 8.58.2(typescript@6.0.2)
|
| 1818 |
+
'@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@6.0.2)
|
| 1819 |
+
eslint: 9.39.4
|
| 1820 |
+
typescript: 6.0.2
|
| 1821 |
+
transitivePeerDependencies:
|
| 1822 |
+
- supports-color
|
| 1823 |
+
|
| 1824 |
+
typescript@6.0.2: {}
|
| 1825 |
+
|
| 1826 |
+
undici-types@7.16.0: {}
|
| 1827 |
+
|
| 1828 |
+
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
| 1829 |
+
dependencies:
|
| 1830 |
+
browserslist: 4.28.2
|
| 1831 |
+
escalade: 3.2.0
|
| 1832 |
+
picocolors: 1.1.1
|
| 1833 |
+
|
| 1834 |
+
uri-js@4.4.1:
|
| 1835 |
+
dependencies:
|
| 1836 |
+
punycode: 2.3.1
|
| 1837 |
+
|
| 1838 |
+
vite@8.0.8(@types/node@24.12.2):
|
| 1839 |
+
dependencies:
|
| 1840 |
+
lightningcss: 1.32.0
|
| 1841 |
+
picomatch: 4.0.4
|
| 1842 |
+
postcss: 8.5.10
|
| 1843 |
+
rolldown: 1.0.0-rc.15
|
| 1844 |
+
tinyglobby: 0.2.16
|
| 1845 |
+
optionalDependencies:
|
| 1846 |
+
'@types/node': 24.12.2
|
| 1847 |
+
fsevents: 2.3.3
|
| 1848 |
+
|
| 1849 |
+
which@2.0.2:
|
| 1850 |
+
dependencies:
|
| 1851 |
+
isexe: 2.0.0
|
| 1852 |
+
|
| 1853 |
+
word-wrap@1.2.5: {}
|
| 1854 |
+
|
| 1855 |
+
yallist@3.1.1: {}
|
| 1856 |
+
|
| 1857 |
+
yocto-queue@0.1.0: {}
|
| 1858 |
+
|
| 1859 |
+
zod-validation-error@4.0.2(zod@4.3.6):
|
| 1860 |
+
dependencies:
|
| 1861 |
+
zod: 4.3.6
|
| 1862 |
+
|
| 1863 |
+
zod@4.3.6: {}
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* {
|
| 2 |
+
box-sizing: border-box;
|
| 3 |
+
margin: 0;
|
| 4 |
+
padding: 0;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
body {
|
| 8 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 9 |
+
background: #0f1117;
|
| 10 |
+
color: #e0e0e0;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.app-layout {
|
| 14 |
+
display: flex;
|
| 15 |
+
height: 100vh;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* ── Sidebar ──────────────────────────────────────────────────────────── */
|
| 19 |
+
|
| 20 |
+
.sidebar {
|
| 21 |
+
width: 320px;
|
| 22 |
+
background: #1a1d27;
|
| 23 |
+
padding: 20px;
|
| 24 |
+
display: flex;
|
| 25 |
+
flex-direction: column;
|
| 26 |
+
gap: 16px;
|
| 27 |
+
overflow-y: auto;
|
| 28 |
+
border-right: 1px solid #2a2d37;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.app-title {
|
| 32 |
+
font-size: 20px;
|
| 33 |
+
font-weight: 600;
|
| 34 |
+
color: #fff;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.sidebar-section {
|
| 38 |
+
display: flex;
|
| 39 |
+
flex-direction: column;
|
| 40 |
+
gap: 8px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.toggle-label {
|
| 44 |
+
display: flex;
|
| 45 |
+
align-items: center;
|
| 46 |
+
gap: 8px;
|
| 47 |
+
cursor: pointer;
|
| 48 |
+
font-size: 14px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* ── Forms ─────────────────────────────────────────────────────────── */
|
| 52 |
+
|
| 53 |
+
.persona-selector {
|
| 54 |
+
display: flex;
|
| 55 |
+
flex-direction: column;
|
| 56 |
+
gap: 4px;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
label {
|
| 60 |
+
font-size: 12px;
|
| 61 |
+
color: #888;
|
| 62 |
+
text-transform: uppercase;
|
| 63 |
+
letter-spacing: 0.5px;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
select, input[type="text"] {
|
| 67 |
+
background: #252830;
|
| 68 |
+
color: #e0e0e0;
|
| 69 |
+
border: 1px solid #3a3d47;
|
| 70 |
+
border-radius: 6px;
|
| 71 |
+
padding: 8px 10px;
|
| 72 |
+
font-size: 14px;
|
| 73 |
+
outline: none;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
select:focus, input[type="text"]:focus {
|
| 77 |
+
border-color: #5b8def;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* ── Webcam ────────────────────────────────────────────────────────── */
|
| 81 |
+
|
| 82 |
+
.webcam-container {
|
| 83 |
+
border-radius: 8px;
|
| 84 |
+
overflow: hidden;
|
| 85 |
+
background: #252830;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.webcam-placeholder, .webcam-error {
|
| 89 |
+
padding: 24px;
|
| 90 |
+
text-align: center;
|
| 91 |
+
color: #666;
|
| 92 |
+
font-size: 13px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.webcam-error {
|
| 96 |
+
color: #e55;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* ── Sensing status ───────────────────────────────────────────────── */
|
| 100 |
+
|
| 101 |
+
.sensing-off {
|
| 102 |
+
color: #666;
|
| 103 |
+
font-size: 13px;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.sensing-status {
|
| 107 |
+
display: flex;
|
| 108 |
+
flex-direction: column;
|
| 109 |
+
gap: 4px;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.sensing-row {
|
| 113 |
+
display: flex;
|
| 114 |
+
justify-content: space-between;
|
| 115 |
+
font-size: 13px;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.sensing-label {
|
| 119 |
+
color: #888;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.sensing-value {
|
| 123 |
+
color: #ccc;
|
| 124 |
+
font-weight: 500;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/* ── Latency metrics ──────────────────────────────────────────────── */
|
| 128 |
+
|
| 129 |
+
.latency-metrics h3 {
|
| 130 |
+
font-size: 12px;
|
| 131 |
+
color: #888;
|
| 132 |
+
text-transform: uppercase;
|
| 133 |
+
letter-spacing: 0.5px;
|
| 134 |
+
margin-bottom: 6px;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.metric-row {
|
| 138 |
+
display: flex;
|
| 139 |
+
justify-content: space-between;
|
| 140 |
+
font-size: 13px;
|
| 141 |
+
padding: 2px 0;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.metric-label {
|
| 145 |
+
color: #888;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.metric-value {
|
| 149 |
+
color: #ccc;
|
| 150 |
+
font-family: monospace;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.no-metrics {
|
| 154 |
+
color: #555;
|
| 155 |
+
font-size: 13px;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* ── Main content ─────────────────────────────────────────────────── */
|
| 159 |
+
|
| 160 |
+
.main-content {
|
| 161 |
+
flex: 1;
|
| 162 |
+
display: flex;
|
| 163 |
+
flex-direction: column;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* ── Chat panel ───────────────────────────────────────────────────── */
|
| 167 |
+
|
| 168 |
+
.chat-panel {
|
| 169 |
+
display: flex;
|
| 170 |
+
flex-direction: column;
|
| 171 |
+
height: 100%;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.chat-header {
|
| 175 |
+
padding: 16px 24px;
|
| 176 |
+
font-size: 16px;
|
| 177 |
+
font-weight: 600;
|
| 178 |
+
border-bottom: 1px solid #2a2d37;
|
| 179 |
+
background: #1a1d27;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.chat-messages {
|
| 183 |
+
flex: 1;
|
| 184 |
+
overflow-y: auto;
|
| 185 |
+
padding: 20px 24px;
|
| 186 |
+
display: flex;
|
| 187 |
+
flex-direction: column;
|
| 188 |
+
gap: 12px;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.chat-bubble {
|
| 192 |
+
max-width: 75%;
|
| 193 |
+
padding: 10px 14px;
|
| 194 |
+
border-radius: 12px;
|
| 195 |
+
font-size: 14px;
|
| 196 |
+
line-height: 1.5;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.chat-bubble.partner {
|
| 200 |
+
align-self: flex-end;
|
| 201 |
+
background: #2a4a8a;
|
| 202 |
+
border-bottom-right-radius: 4px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.chat-bubble.aac_user {
|
| 206 |
+
align-self: flex-start;
|
| 207 |
+
background: #252830;
|
| 208 |
+
border-bottom-left-radius: 4px;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.chat-bubble.loading {
|
| 212 |
+
opacity: 0.6;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.chat-role {
|
| 216 |
+
display: block;
|
| 217 |
+
font-size: 11px;
|
| 218 |
+
color: #888;
|
| 219 |
+
margin-bottom: 4px;
|
| 220 |
+
text-transform: uppercase;
|
| 221 |
+
letter-spacing: 0.5px;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.chat-bubble p {
|
| 225 |
+
margin: 0;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.chat-input-row {
|
| 229 |
+
display: flex;
|
| 230 |
+
gap: 8px;
|
| 231 |
+
padding: 16px 24px;
|
| 232 |
+
border-top: 1px solid #2a2d37;
|
| 233 |
+
background: #1a1d27;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.chat-input-row input {
|
| 237 |
+
flex: 1;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.chat-input-row button {
|
| 241 |
+
background: #5b8def;
|
| 242 |
+
color: #fff;
|
| 243 |
+
border: none;
|
| 244 |
+
border-radius: 6px;
|
| 245 |
+
padding: 8px 20px;
|
| 246 |
+
font-size: 14px;
|
| 247 |
+
cursor: pointer;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.chat-input-row button:disabled {
|
| 251 |
+
opacity: 0.4;
|
| 252 |
+
cursor: not-allowed;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.chat-input-row button:hover:not(:disabled) {
|
| 256 |
+
background: #4a7cde;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.error {
|
| 260 |
+
color: #e55;
|
| 261 |
+
font-size: 13px;
|
| 262 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback, useEffect, useRef } from "react";
|
| 2 |
+
import type { Persona, Affect, ChatMessage, LatencyLog } from "./types";
|
| 3 |
+
import { resetSession, checkHealth } from "./lib/api";
|
| 4 |
+
import { useWebcam } from "./hooks/useWebcam";
|
| 5 |
+
import { useSensing } from "./hooks/useSensing";
|
| 6 |
+
import { PersonaSelector } from "./components/PersonaSelector";
|
| 7 |
+
import { ChatPanel } from "./components/ChatPanel";
|
| 8 |
+
import { WebcamSensing } from "./components/WebcamSensing";
|
| 9 |
+
import { SensingStatus } from "./components/SensingStatus";
|
| 10 |
+
import { LatencyMetrics } from "./components/LatencyMetrics";
|
| 11 |
+
import "./App.css";
|
| 12 |
+
|
| 13 |
+
function App() {
|
| 14 |
+
const [persona, setPersona] = useState<Persona | null>(null);
|
| 15 |
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
| 16 |
+
const [latency, setLatency] = useState<LatencyLog | null>(null);
|
| 17 |
+
const [webcamEnabled, setWebcamEnabled] = useState(false);
|
| 18 |
+
const [affectOverride, setAffectOverride] = useState<Affect | null>(null);
|
| 19 |
+
const [backendReady, setBackendReady] = useState(false);
|
| 20 |
+
const healthPoll = useRef<ReturnType<typeof setInterval>>(undefined);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
async function poll() {
|
| 24 |
+
const ready = await checkHealth();
|
| 25 |
+
if (ready) {
|
| 26 |
+
setBackendReady(true);
|
| 27 |
+
clearInterval(healthPoll.current);
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
poll();
|
| 31 |
+
healthPoll.current = setInterval(poll, 2000);
|
| 32 |
+
return () => clearInterval(healthPoll.current);
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
const { sensing, ready, initError, init, processFrame, clearAirWrittenText, resetCalibration } =
|
| 36 |
+
useSensing();
|
| 37 |
+
|
| 38 |
+
const onFrame = useCallback(
|
| 39 |
+
(video: HTMLVideoElement, timestamp: number) => {
|
| 40 |
+
processFrame(video, timestamp);
|
| 41 |
+
},
|
| 42 |
+
[processFrame]
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
const { videoRef, active, error } = useWebcam({
|
| 46 |
+
enabled: webcamEnabled && ready,
|
| 47 |
+
onFrame,
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
async function handleWebcamToggle() {
|
| 51 |
+
if (!webcamEnabled) {
|
| 52 |
+
const ok = await init();
|
| 53 |
+
if (ok) setWebcamEnabled(true);
|
| 54 |
+
} else {
|
| 55 |
+
setWebcamEnabled(false);
|
| 56 |
+
resetCalibration();
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
async function handlePersonaSelect(p: Persona) {
|
| 61 |
+
setPersona(p);
|
| 62 |
+
setMessages([]);
|
| 63 |
+
setLatency(null);
|
| 64 |
+
try {
|
| 65 |
+
await resetSession(p.id);
|
| 66 |
+
} catch {
|
| 67 |
+
// Session reset failed — non-critical, continue with fresh UI state
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<div className="app-layout">
|
| 73 |
+
<aside className="sidebar">
|
| 74 |
+
<h1 className="app-title">AAC Chatbot</h1>
|
| 75 |
+
|
| 76 |
+
<PersonaSelector
|
| 77 |
+
selected={persona?.id ?? null}
|
| 78 |
+
onSelect={handlePersonaSelect}
|
| 79 |
+
/>
|
| 80 |
+
|
| 81 |
+
<div className="sidebar-section">
|
| 82 |
+
<label className="toggle-label">
|
| 83 |
+
<input
|
| 84 |
+
type="checkbox"
|
| 85 |
+
checked={webcamEnabled}
|
| 86 |
+
onChange={handleWebcamToggle}
|
| 87 |
+
/>
|
| 88 |
+
Enable webcam
|
| 89 |
+
</label>
|
| 90 |
+
<WebcamSensing videoRef={videoRef} active={active} error={error || initError} />
|
| 91 |
+
<SensingStatus sensing={sensing} webcamActive={active} />
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="sidebar-section">
|
| 95 |
+
<label htmlFor="affect-override">Affect override</label>
|
| 96 |
+
<select
|
| 97 |
+
id="affect-override"
|
| 98 |
+
value={affectOverride ?? "auto"}
|
| 99 |
+
onChange={(e) =>
|
| 100 |
+
setAffectOverride(
|
| 101 |
+
e.target.value === "auto" ? null : (e.target.value as Affect)
|
| 102 |
+
)
|
| 103 |
+
}
|
| 104 |
+
>
|
| 105 |
+
<option value="auto">Auto (webcam)</option>
|
| 106 |
+
<option value="HAPPY">HAPPY</option>
|
| 107 |
+
<option value="FRUSTRATED">FRUSTRATED</option>
|
| 108 |
+
<option value="NEUTRAL">NEUTRAL</option>
|
| 109 |
+
<option value="SURPRISED">SURPRISED</option>
|
| 110 |
+
</select>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<LatencyMetrics latency={latency} />
|
| 114 |
+
</aside>
|
| 115 |
+
|
| 116 |
+
<main className="main-content">
|
| 117 |
+
<ChatPanel
|
| 118 |
+
userId={persona?.id ?? null}
|
| 119 |
+
personaName={persona?.name ?? ""}
|
| 120 |
+
sensing={sensing}
|
| 121 |
+
affectOverride={affectOverride}
|
| 122 |
+
onAirTextConsumed={clearAirWrittenText}
|
| 123 |
+
messages={messages}
|
| 124 |
+
setMessages={setMessages}
|
| 125 |
+
onLatency={setLatency}
|
| 126 |
+
backendReady={backendReady}
|
| 127 |
+
/>
|
| 128 |
+
</main>
|
| 129 |
+
</div>
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export default App;
|
frontend/src/components/ChatPanel.tsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from "react";
|
| 2 |
+
import type { ChatMessage, SensingState, Affect, LatencyLog } from "../types";
|
| 3 |
+
import { sendChat } from "../lib/api";
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
userId: string | null;
|
| 7 |
+
personaName: string;
|
| 8 |
+
sensing: SensingState;
|
| 9 |
+
affectOverride: Affect | null;
|
| 10 |
+
onAirTextConsumed: () => void;
|
| 11 |
+
messages: ChatMessage[];
|
| 12 |
+
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
| 13 |
+
onLatency: (latency: LatencyLog) => void;
|
| 14 |
+
backendReady: boolean;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function ChatPanel({
|
| 18 |
+
userId,
|
| 19 |
+
personaName,
|
| 20 |
+
sensing,
|
| 21 |
+
affectOverride,
|
| 22 |
+
onAirTextConsumed,
|
| 23 |
+
messages,
|
| 24 |
+
setMessages,
|
| 25 |
+
onLatency,
|
| 26 |
+
backendReady,
|
| 27 |
+
}: Props) {
|
| 28 |
+
const [input, setInput] = useState("");
|
| 29 |
+
const [loading, setLoading] = useState(false);
|
| 30 |
+
const bottomRef = useRef<HTMLDivElement>(null);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 34 |
+
}, [messages]);
|
| 35 |
+
|
| 36 |
+
async function handleSend() {
|
| 37 |
+
if (!input.trim() || !userId || !backendReady || loading) return;
|
| 38 |
+
|
| 39 |
+
const query = input.trim();
|
| 40 |
+
setInput("");
|
| 41 |
+
setMessages((prev) => [...prev, { role: "partner", content: query }]);
|
| 42 |
+
setLoading(true);
|
| 43 |
+
|
| 44 |
+
const airText = sensing.airWrittenText || null;
|
| 45 |
+
try {
|
| 46 |
+
const res = await sendChat({
|
| 47 |
+
user_id: userId,
|
| 48 |
+
query,
|
| 49 |
+
affect_override: affectOverride ?? sensing.affect,
|
| 50 |
+
gesture_tag: sensing.gestureTag,
|
| 51 |
+
gaze_bucket: sensing.gazeBucket,
|
| 52 |
+
air_written_text: airText,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
setMessages((prev) => [
|
| 56 |
+
...prev,
|
| 57 |
+
{
|
| 58 |
+
role: "aac_user",
|
| 59 |
+
content: res.response,
|
| 60 |
+
latency: res.latency,
|
| 61 |
+
affect: res.affect,
|
| 62 |
+
},
|
| 63 |
+
]);
|
| 64 |
+
onLatency(res.latency);
|
| 65 |
+
} catch (e) {
|
| 66 |
+
setMessages((prev) => [
|
| 67 |
+
...prev,
|
| 68 |
+
{
|
| 69 |
+
role: "aac_user",
|
| 70 |
+
content: `Error: ${e instanceof Error ? e.message : "request failed"}`,
|
| 71 |
+
},
|
| 72 |
+
]);
|
| 73 |
+
} finally {
|
| 74 |
+
if (airText) onAirTextConsumed();
|
| 75 |
+
setLoading(false);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div className="chat-panel">
|
| 81 |
+
<div className="chat-header">
|
| 82 |
+
Talking as: {personaName || "select a persona"}
|
| 83 |
+
</div>
|
| 84 |
+
<div className="chat-messages">
|
| 85 |
+
{messages.map((msg, i) => (
|
| 86 |
+
<div key={i} className={`chat-bubble ${msg.role}`}>
|
| 87 |
+
<span className="chat-role">
|
| 88 |
+
{msg.role === "partner" ? "Partner" : "AAC User"}
|
| 89 |
+
</span>
|
| 90 |
+
<p>{msg.content}</p>
|
| 91 |
+
</div>
|
| 92 |
+
))}
|
| 93 |
+
{loading && (
|
| 94 |
+
<div className="chat-bubble aac_user loading">
|
| 95 |
+
<span className="chat-role">AAC User</span>
|
| 96 |
+
<p>Generating...</p>
|
| 97 |
+
</div>
|
| 98 |
+
)}
|
| 99 |
+
<div ref={bottomRef} />
|
| 100 |
+
</div>
|
| 101 |
+
<div className="chat-input-row">
|
| 102 |
+
<input
|
| 103 |
+
type="text"
|
| 104 |
+
value={input}
|
| 105 |
+
onChange={(e) => setInput(e.target.value)}
|
| 106 |
+
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
| 107 |
+
placeholder={backendReady ? "Type as the communication partner..." : "Waiting for backend to load models..."}
|
| 108 |
+
disabled={!userId || loading || !backendReady}
|
| 109 |
+
/>
|
| 110 |
+
<button onClick={handleSend} disabled={!userId || loading || !backendReady || !input.trim()}>
|
| 111 |
+
Send
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
);
|
| 116 |
+
}
|
frontend/src/components/LatencyMetrics.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { LatencyLog } from "../types";
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
latency: LatencyLog | null;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
const FIELDS: { key: keyof LatencyLog; label: string }[] = [
|
| 8 |
+
{ key: "t_sensing", label: "Sensing" },
|
| 9 |
+
{ key: "t_intent", label: "Intent" },
|
| 10 |
+
{ key: "t_retrieval", label: "Retrieval" },
|
| 11 |
+
{ key: "t_generation", label: "Generation" },
|
| 12 |
+
{ key: "t_total", label: "Total" },
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
export function LatencyMetrics({ latency }: Props) {
|
| 16 |
+
if (!latency) return <p className="no-metrics">No turn yet</p>;
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="latency-metrics">
|
| 20 |
+
<h3>Latency</h3>
|
| 21 |
+
{FIELDS.map(({ key, label }) => (
|
| 22 |
+
<div key={key} className="metric-row">
|
| 23 |
+
<span className="metric-label">{label}</span>
|
| 24 |
+
<span className="metric-value">{(latency[key] ?? 0).toFixed(3)}s</span>
|
| 25 |
+
</div>
|
| 26 |
+
))}
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
frontend/src/components/PersonaSelector.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import type { Persona } from "../types";
|
| 3 |
+
import { fetchUsers } from "../lib/api";
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
selected: string | null;
|
| 7 |
+
onSelect: (persona: Persona) => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function PersonaSelector({ selected, onSelect }: Props) {
|
| 11 |
+
const [personas, setPersonas] = useState<Persona[]>([]);
|
| 12 |
+
const [error, setError] = useState<string | null>(null);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
fetchUsers()
|
| 16 |
+
.then(setPersonas)
|
| 17 |
+
.catch(() => setError("Cannot reach API — start the FastAPI server"));
|
| 18 |
+
}, []);
|
| 19 |
+
|
| 20 |
+
if (error) return <p className="error">{error}</p>;
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="persona-selector">
|
| 24 |
+
<label htmlFor="persona">Persona</label>
|
| 25 |
+
<select
|
| 26 |
+
id="persona"
|
| 27 |
+
value={selected ?? ""}
|
| 28 |
+
onChange={(e) => {
|
| 29 |
+
const p = personas.find((p) => p.id === e.target.value);
|
| 30 |
+
if (p) onSelect(p);
|
| 31 |
+
}}
|
| 32 |
+
>
|
| 33 |
+
<option value="" disabled>Select a persona</option>
|
| 34 |
+
{personas.map((p) => (
|
| 35 |
+
<option key={p.id} value={p.id}>
|
| 36 |
+
{p.name} ({p.condition})
|
| 37 |
+
</option>
|
| 38 |
+
))}
|
| 39 |
+
</select>
|
| 40 |
+
</div>
|
| 41 |
+
);
|
| 42 |
+
}
|
frontend/src/components/SensingStatus.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SensingState } from "../types";
|
| 2 |
+
|
| 3 |
+
const AFFECT_EMOJI: Record<string, string> = {
|
| 4 |
+
HAPPY: "\ud83d\ude0a",
|
| 5 |
+
FRUSTRATED: "\ud83d\ude24",
|
| 6 |
+
NEUTRAL: "\ud83d\ude10",
|
| 7 |
+
SURPRISED: "\ud83d\ude32",
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
interface Props {
|
| 11 |
+
sensing: SensingState;
|
| 12 |
+
webcamActive: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function SensingStatus({ sensing, webcamActive }: Props) {
|
| 16 |
+
if (!webcamActive) {
|
| 17 |
+
return <p className="sensing-off">Webcam off</p>;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div className="sensing-status">
|
| 22 |
+
<div className="sensing-row">
|
| 23 |
+
<span className="sensing-label">Affect</span>
|
| 24 |
+
<span className="sensing-value">
|
| 25 |
+
{AFFECT_EMOJI[sensing.affect ?? "NEUTRAL"]}{" "}
|
| 26 |
+
{sensing.affect ?? "NEUTRAL"}
|
| 27 |
+
</span>
|
| 28 |
+
</div>
|
| 29 |
+
<div className="sensing-row">
|
| 30 |
+
<span className="sensing-label">Gesture</span>
|
| 31 |
+
<span className="sensing-value">
|
| 32 |
+
{sensing.gestureTag ?? "none"}
|
| 33 |
+
</span>
|
| 34 |
+
</div>
|
| 35 |
+
<div className="sensing-row">
|
| 36 |
+
<span className="sensing-label">Gaze</span>
|
| 37 |
+
<span className="sensing-value">
|
| 38 |
+
{sensing.gazeBucket ?? "none"}
|
| 39 |
+
</span>
|
| 40 |
+
</div>
|
| 41 |
+
{sensing.airWrittenText && (
|
| 42 |
+
<div className="sensing-row">
|
| 43 |
+
<span className="sensing-label">Air-written</span>
|
| 44 |
+
<span className="sensing-value">{sensing.airWrittenText}</span>
|
| 45 |
+
</div>
|
| 46 |
+
)}
|
| 47 |
+
</div>
|
| 48 |
+
);
|
| 49 |
+
}
|
frontend/src/components/WebcamSensing.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { RefObject } from "react";
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
videoRef: RefObject<HTMLVideoElement | null>;
|
| 5 |
+
active: boolean;
|
| 6 |
+
error: string | null;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function WebcamSensing({ videoRef, active, error }: Props) {
|
| 10 |
+
return (
|
| 11 |
+
<div className="webcam-container">
|
| 12 |
+
<video
|
| 13 |
+
ref={videoRef}
|
| 14 |
+
autoPlay
|
| 15 |
+
playsInline
|
| 16 |
+
muted
|
| 17 |
+
style={{
|
| 18 |
+
width: "100%",
|
| 19 |
+
borderRadius: 8,
|
| 20 |
+
display: active ? "block" : "none",
|
| 21 |
+
transform: "scaleX(-1)",
|
| 22 |
+
}}
|
| 23 |
+
/>
|
| 24 |
+
{!active && !error && (
|
| 25 |
+
<div className="webcam-placeholder">Camera off</div>
|
| 26 |
+
)}
|
| 27 |
+
{error && <div className="webcam-error">{error}</div>}
|
| 28 |
+
</div>
|
| 29 |
+
);
|
| 30 |
+
}
|
frontend/src/hooks/useSensing.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useCallback, useState, useEffect } from "react";
|
| 2 |
+
import {
|
| 3 |
+
FaceLandmarker,
|
| 4 |
+
HandLandmarker,
|
| 5 |
+
FilesetResolver,
|
| 6 |
+
} from "@mediapipe/tasks-vision";
|
| 7 |
+
import type { SensingState } from "../types";
|
| 8 |
+
import {
|
| 9 |
+
computeAffectVector,
|
| 10 |
+
classifyAffect,
|
| 11 |
+
classifyGesture,
|
| 12 |
+
GazeTracker,
|
| 13 |
+
AirWriter,
|
| 14 |
+
} from "../lib/sensing";
|
| 15 |
+
|
| 16 |
+
const EMA_ALPHA = 0.3;
|
| 17 |
+
|
| 18 |
+
export function useSensing() {
|
| 19 |
+
const faceLandmarkerRef = useRef<FaceLandmarker | null>(null);
|
| 20 |
+
const handLandmarkerRef = useRef<HandLandmarker | null>(null);
|
| 21 |
+
const gazeTrackerRef = useRef(new GazeTracker());
|
| 22 |
+
const airWriterRef = useRef(new AirWriter());
|
| 23 |
+
const neutralLCPRef = useRef<number | null>(null);
|
| 24 |
+
const smoothedRef = useRef({ MAR: 0, EAR: 0.3, BRI: -0.3, LCP: 0 });
|
| 25 |
+
const initingRef = useRef(false);
|
| 26 |
+
const [ready, setReady] = useState(false);
|
| 27 |
+
const [initError, setInitError] = useState<string | null>(null);
|
| 28 |
+
const [sensing, setSensing] = useState<SensingState>({
|
| 29 |
+
affect: null,
|
| 30 |
+
gestureTag: null,
|
| 31 |
+
gazeBucket: null,
|
| 32 |
+
airWrittenText: "",
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
// Cleanup MediaPipe resources on unmount
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
return () => {
|
| 38 |
+
faceLandmarkerRef.current?.close();
|
| 39 |
+
handLandmarkerRef.current?.close();
|
| 40 |
+
faceLandmarkerRef.current = null;
|
| 41 |
+
handLandmarkerRef.current = null;
|
| 42 |
+
};
|
| 43 |
+
}, []);
|
| 44 |
+
|
| 45 |
+
const init = useCallback(async (): Promise<boolean> => {
|
| 46 |
+
if (faceLandmarkerRef.current || initingRef.current) return true;
|
| 47 |
+
initingRef.current = true;
|
| 48 |
+
try {
|
| 49 |
+
const vision = await FilesetResolver.forVisionTasks(
|
| 50 |
+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"
|
| 51 |
+
);
|
| 52 |
+
faceLandmarkerRef.current = await FaceLandmarker.createFromOptions(
|
| 53 |
+
vision,
|
| 54 |
+
{
|
| 55 |
+
baseOptions: {
|
| 56 |
+
modelAssetPath:
|
| 57 |
+
"https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
|
| 58 |
+
delegate: "GPU",
|
| 59 |
+
},
|
| 60 |
+
runningMode: "VIDEO",
|
| 61 |
+
numFaces: 1,
|
| 62 |
+
outputFaceBlendshapes: false,
|
| 63 |
+
outputFacialTransformationMatrixes: false,
|
| 64 |
+
}
|
| 65 |
+
);
|
| 66 |
+
handLandmarkerRef.current = await HandLandmarker.createFromOptions(
|
| 67 |
+
vision,
|
| 68 |
+
{
|
| 69 |
+
baseOptions: {
|
| 70 |
+
modelAssetPath:
|
| 71 |
+
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
|
| 72 |
+
delegate: "GPU",
|
| 73 |
+
},
|
| 74 |
+
runningMode: "VIDEO",
|
| 75 |
+
numHands: 1,
|
| 76 |
+
}
|
| 77 |
+
);
|
| 78 |
+
setReady(true);
|
| 79 |
+
return true;
|
| 80 |
+
} catch (e) {
|
| 81 |
+
setInitError(
|
| 82 |
+
e instanceof Error ? e.message : "Failed to load MediaPipe models"
|
| 83 |
+
);
|
| 84 |
+
return false;
|
| 85 |
+
} finally {
|
| 86 |
+
initingRef.current = false;
|
| 87 |
+
}
|
| 88 |
+
}, []);
|
| 89 |
+
|
| 90 |
+
const processFrame = useCallback(
|
| 91 |
+
(video: HTMLVideoElement, timestamp: number) => {
|
| 92 |
+
const faceLandmarker = faceLandmarkerRef.current;
|
| 93 |
+
const handLandmarker = handLandmarkerRef.current;
|
| 94 |
+
if (!faceLandmarker || !handLandmarker) return;
|
| 95 |
+
|
| 96 |
+
let affect: SensingState["affect"] = null;
|
| 97 |
+
let gazeBucket: SensingState["gazeBucket"] = null;
|
| 98 |
+
|
| 99 |
+
const faceResult = faceLandmarker.detectForVideo(video, timestamp);
|
| 100 |
+
if (faceResult.faceLandmarks && faceResult.faceLandmarks.length > 0) {
|
| 101 |
+
const landmarks = faceResult.faceLandmarks[0];
|
| 102 |
+
|
| 103 |
+
if (neutralLCPRef.current === null) {
|
| 104 |
+
neutralLCPRef.current =
|
| 105 |
+
(landmarks[61].x + landmarks[291].x) / 2;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const raw = computeAffectVector(landmarks, neutralLCPRef.current);
|
| 109 |
+
|
| 110 |
+
const prev = smoothedRef.current;
|
| 111 |
+
const smoothed = {
|
| 112 |
+
MAR: EMA_ALPHA * raw.MAR + (1 - EMA_ALPHA) * prev.MAR,
|
| 113 |
+
EAR: EMA_ALPHA * raw.EAR + (1 - EMA_ALPHA) * prev.EAR,
|
| 114 |
+
BRI: EMA_ALPHA * raw.BRI + (1 - EMA_ALPHA) * prev.BRI,
|
| 115 |
+
LCP: EMA_ALPHA * raw.LCP + (1 - EMA_ALPHA) * prev.LCP,
|
| 116 |
+
};
|
| 117 |
+
smoothedRef.current = smoothed;
|
| 118 |
+
|
| 119 |
+
affect = classifyAffect(smoothed);
|
| 120 |
+
gazeBucket = gazeTrackerRef.current.process(landmarks);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
let gestureTag: SensingState["gestureTag"] = null;
|
| 124 |
+
|
| 125 |
+
const handResult = handLandmarker.detectForVideo(video, timestamp);
|
| 126 |
+
if (handResult.landmarks && handResult.landmarks.length > 0) {
|
| 127 |
+
const handLandmarks = handResult.landmarks[0];
|
| 128 |
+
gestureTag = classifyGesture(handLandmarks);
|
| 129 |
+
airWriterRef.current.processHandLandmarks(
|
| 130 |
+
handLandmarks,
|
| 131 |
+
video.videoWidth,
|
| 132 |
+
video.videoHeight
|
| 133 |
+
);
|
| 134 |
+
} else {
|
| 135 |
+
airWriterRef.current.noHand();
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const newAirText = airWriterRef.current.getText();
|
| 139 |
+
|
| 140 |
+
setSensing((prev) => ({
|
| 141 |
+
affect: affect ?? prev.affect,
|
| 142 |
+
gestureTag: gestureTag ?? prev.gestureTag,
|
| 143 |
+
gazeBucket: gazeBucket ?? prev.gazeBucket,
|
| 144 |
+
airWrittenText: newAirText
|
| 145 |
+
? prev.airWrittenText + newAirText
|
| 146 |
+
: prev.airWrittenText,
|
| 147 |
+
}));
|
| 148 |
+
},
|
| 149 |
+
[]
|
| 150 |
+
);
|
| 151 |
+
|
| 152 |
+
const clearAirWrittenText = useCallback(() => {
|
| 153 |
+
setSensing((prev) => ({ ...prev, airWrittenText: "" }));
|
| 154 |
+
}, []);
|
| 155 |
+
|
| 156 |
+
const resetCalibration = useCallback(() => {
|
| 157 |
+
neutralLCPRef.current = null;
|
| 158 |
+
smoothedRef.current = { MAR: 0, EAR: 0.3, BRI: -0.3, LCP: 0 };
|
| 159 |
+
gazeTrackerRef.current.reset();
|
| 160 |
+
setSensing({ affect: null, gestureTag: null, gazeBucket: null, airWrittenText: "" });
|
| 161 |
+
}, []);
|
| 162 |
+
|
| 163 |
+
return { sensing, ready, initError, init, processFrame, clearAirWrittenText, resetCalibration };
|
| 164 |
+
}
|
frontend/src/hooks/useWebcam.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useEffect, useState } from "react";
|
| 2 |
+
|
| 3 |
+
interface UseWebcamOptions {
|
| 4 |
+
enabled: boolean;
|
| 5 |
+
onFrame?: (video: HTMLVideoElement, timestamp: number) => void;
|
| 6 |
+
processEveryN?: number;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function useWebcam({
|
| 10 |
+
enabled,
|
| 11 |
+
onFrame,
|
| 12 |
+
processEveryN = 3,
|
| 13 |
+
}: UseWebcamOptions) {
|
| 14 |
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
| 15 |
+
const streamRef = useRef<MediaStream | null>(null);
|
| 16 |
+
const frameCount = useRef(0);
|
| 17 |
+
const rafId = useRef(0);
|
| 18 |
+
const onFrameRef = useRef(onFrame);
|
| 19 |
+
const [error, setError] = useState<string | null>(null);
|
| 20 |
+
const [active, setActive] = useState(false);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
onFrameRef.current = onFrame;
|
| 24 |
+
}, [onFrame]);
|
| 25 |
+
|
| 26 |
+
function teardown() {
|
| 27 |
+
if (rafId.current) cancelAnimationFrame(rafId.current);
|
| 28 |
+
rafId.current = 0;
|
| 29 |
+
if (streamRef.current) {
|
| 30 |
+
streamRef.current.getTracks().forEach((t) => t.stop());
|
| 31 |
+
streamRef.current = null;
|
| 32 |
+
}
|
| 33 |
+
if (videoRef.current) {
|
| 34 |
+
videoRef.current.srcObject = null;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
if (!enabled) {
|
| 40 |
+
teardown();
|
| 41 |
+
setActive(false);
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
let cancelled = false;
|
| 46 |
+
|
| 47 |
+
async function start() {
|
| 48 |
+
try {
|
| 49 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 50 |
+
video: { facingMode: "user", width: 640, height: 480 },
|
| 51 |
+
});
|
| 52 |
+
if (cancelled) {
|
| 53 |
+
stream.getTracks().forEach((t) => t.stop());
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
streamRef.current = stream;
|
| 57 |
+
if (videoRef.current) {
|
| 58 |
+
videoRef.current.srcObject = stream;
|
| 59 |
+
await videoRef.current.play();
|
| 60 |
+
}
|
| 61 |
+
if (cancelled) {
|
| 62 |
+
teardown();
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
setActive(true);
|
| 66 |
+
setError(null);
|
| 67 |
+
|
| 68 |
+
function loop(timestamp: number) {
|
| 69 |
+
if (cancelled) return;
|
| 70 |
+
frameCount.current++;
|
| 71 |
+
if (
|
| 72 |
+
frameCount.current % processEveryN === 0 &&
|
| 73 |
+
videoRef.current &&
|
| 74 |
+
onFrameRef.current
|
| 75 |
+
) {
|
| 76 |
+
onFrameRef.current(videoRef.current, timestamp);
|
| 77 |
+
}
|
| 78 |
+
rafId.current = requestAnimationFrame(loop);
|
| 79 |
+
}
|
| 80 |
+
rafId.current = requestAnimationFrame(loop);
|
| 81 |
+
} catch (e) {
|
| 82 |
+
if (!cancelled) {
|
| 83 |
+
setError(
|
| 84 |
+
e instanceof Error ? e.message : "Webcam access denied"
|
| 85 |
+
);
|
| 86 |
+
setActive(false);
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
start();
|
| 92 |
+
|
| 93 |
+
return () => {
|
| 94 |
+
cancelled = true;
|
| 95 |
+
teardown();
|
| 96 |
+
setActive(false);
|
| 97 |
+
};
|
| 98 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 99 |
+
}, [enabled, processEveryN]);
|
| 100 |
+
|
| 101 |
+
return { videoRef, active, error };
|
| 102 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--text: #6b6375;
|
| 3 |
+
--text-h: #08060d;
|
| 4 |
+
--bg: #fff;
|
| 5 |
+
--border: #e5e4e7;
|
| 6 |
+
--code-bg: #f4f3ec;
|
| 7 |
+
--accent: #aa3bff;
|
| 8 |
+
--accent-bg: rgba(170, 59, 255, 0.1);
|
| 9 |
+
--accent-border: rgba(170, 59, 255, 0.5);
|
| 10 |
+
--social-bg: rgba(244, 243, 236, 0.5);
|
| 11 |
+
--shadow:
|
| 12 |
+
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
| 13 |
+
|
| 14 |
+
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
| 15 |
+
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
| 16 |
+
--mono: ui-monospace, Consolas, monospace;
|
| 17 |
+
|
| 18 |
+
font: 18px/145% var(--sans);
|
| 19 |
+
letter-spacing: 0.18px;
|
| 20 |
+
color-scheme: light dark;
|
| 21 |
+
color: var(--text);
|
| 22 |
+
background: var(--bg);
|
| 23 |
+
font-synthesis: none;
|
| 24 |
+
text-rendering: optimizeLegibility;
|
| 25 |
+
-webkit-font-smoothing: antialiased;
|
| 26 |
+
-moz-osx-font-smoothing: grayscale;
|
| 27 |
+
|
| 28 |
+
@media (max-width: 1024px) {
|
| 29 |
+
font-size: 16px;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
@media (prefers-color-scheme: dark) {
|
| 34 |
+
:root {
|
| 35 |
+
--text: #9ca3af;
|
| 36 |
+
--text-h: #f3f4f6;
|
| 37 |
+
--bg: #16171d;
|
| 38 |
+
--border: #2e303a;
|
| 39 |
+
--code-bg: #1f2028;
|
| 40 |
+
--accent: #c084fc;
|
| 41 |
+
--accent-bg: rgba(192, 132, 252, 0.15);
|
| 42 |
+
--accent-border: rgba(192, 132, 252, 0.5);
|
| 43 |
+
--social-bg: rgba(47, 48, 58, 0.5);
|
| 44 |
+
--shadow:
|
| 45 |
+
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
#social .button-icon {
|
| 49 |
+
filter: invert(1) brightness(2);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
#root {
|
| 54 |
+
width: 1126px;
|
| 55 |
+
max-width: 100%;
|
| 56 |
+
margin: 0 auto;
|
| 57 |
+
text-align: center;
|
| 58 |
+
border-inline: 1px solid var(--border);
|
| 59 |
+
min-height: 100svh;
|
| 60 |
+
display: flex;
|
| 61 |
+
flex-direction: column;
|
| 62 |
+
box-sizing: border-box;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
body {
|
| 66 |
+
margin: 0;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
h1,
|
| 70 |
+
h2 {
|
| 71 |
+
font-family: var(--heading);
|
| 72 |
+
font-weight: 500;
|
| 73 |
+
color: var(--text-h);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
h1 {
|
| 77 |
+
font-size: 56px;
|
| 78 |
+
letter-spacing: -1.68px;
|
| 79 |
+
margin: 32px 0;
|
| 80 |
+
@media (max-width: 1024px) {
|
| 81 |
+
font-size: 36px;
|
| 82 |
+
margin: 20px 0;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
h2 {
|
| 86 |
+
font-size: 24px;
|
| 87 |
+
line-height: 118%;
|
| 88 |
+
letter-spacing: -0.24px;
|
| 89 |
+
margin: 0 0 8px;
|
| 90 |
+
@media (max-width: 1024px) {
|
| 91 |
+
font-size: 20px;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
p {
|
| 95 |
+
margin: 0;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
code,
|
| 99 |
+
.counter {
|
| 100 |
+
font-family: var(--mono);
|
| 101 |
+
display: inline-flex;
|
| 102 |
+
border-radius: 4px;
|
| 103 |
+
color: var(--text-h);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
code {
|
| 107 |
+
font-size: 15px;
|
| 108 |
+
line-height: 135%;
|
| 109 |
+
padding: 4px 8px;
|
| 110 |
+
background: var(--code-bg);
|
| 111 |
+
}
|
frontend/src/lib/api.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ChatRequest, ChatResponse, Persona } from "../types";
|
| 2 |
+
|
| 3 |
+
const API_BASE = "";
|
| 4 |
+
|
| 5 |
+
export async function fetchUsers(): Promise<Persona[]> {
|
| 6 |
+
const res = await fetch(`${API_BASE}/users`);
|
| 7 |
+
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
| 8 |
+
const data = await res.json();
|
| 9 |
+
return data.users;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export async function sendChat(req: ChatRequest): Promise<ChatResponse> {
|
| 13 |
+
const res = await fetch(`${API_BASE}/chat`, {
|
| 14 |
+
method: "POST",
|
| 15 |
+
headers: { "Content-Type": "application/json" },
|
| 16 |
+
body: JSON.stringify(req),
|
| 17 |
+
});
|
| 18 |
+
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
| 19 |
+
return res.json();
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export async function resetSession(userId: string): Promise<void> {
|
| 23 |
+
const res = await fetch(
|
| 24 |
+
`${API_BASE}/session/reset?user_id=${encodeURIComponent(userId)}`,
|
| 25 |
+
{ method: "POST" }
|
| 26 |
+
);
|
| 27 |
+
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export async function checkHealth(): Promise<boolean> {
|
| 31 |
+
try {
|
| 32 |
+
const res = await fetch(`${API_BASE}/health`);
|
| 33 |
+
if (!res.ok) return false;
|
| 34 |
+
const data = await res.json();
|
| 35 |
+
return data.models_ready === true;
|
| 36 |
+
} catch {
|
| 37 |
+
return false;
|
| 38 |
+
}
|
| 39 |
+
}
|