shwetangisingh commited on
Commit
375924d
·
1 Parent(s): 035798d

Code refactor for extensibility

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +1 -1
  2. .gitignore +6 -1
  3. .pre-commit-config.yaml +16 -0
  4. CLAUDE.md +35 -26
  5. README.md +58 -148
  6. {api → backend}/__init__.py +0 -0
  7. {generation → backend/api}/__init__.py +0 -0
  8. {api → backend/api}/main.py +56 -27
  9. {config → backend/config}/__init__.py +1 -1
  10. {config → backend/config}/settings.py +19 -25
  11. {guardrails → backend/generation}/__init__.py +0 -0
  12. {generation → backend/generation}/llm_client.py +21 -55
  13. {pipeline → backend/guardrails}/__init__.py +0 -0
  14. {guardrails → backend/guardrails}/checks.py +25 -34
  15. main.py → backend/main.py +66 -49
  16. {pipeline/nodes → backend/pipeline}/__init__.py +0 -0
  17. {pipeline → backend/pipeline}/graph.py +12 -18
  18. {retrieval → backend/pipeline/nodes}/__init__.py +0 -0
  19. {pipeline → backend/pipeline}/nodes/feedback.py +34 -39
  20. {pipeline → backend/pipeline}/nodes/intent.py +45 -24
  21. {pipeline → backend/pipeline}/nodes/planner.py +76 -56
  22. {pipeline → backend/pipeline}/nodes/retrieval.py +15 -23
  23. {pipeline → backend/pipeline}/state.py +37 -38
  24. {sensing → backend/retrieval}/__init__.py +0 -0
  25. {retrieval → backend/retrieval}/bucket_priors.py +2 -23
  26. {retrieval → backend/retrieval}/clustering.py +8 -35
  27. {retrieval → backend/retrieval}/vector_store.py +34 -54
  28. backend/sensing/__init__.py +0 -0
  29. {sensing → backend/sensing}/air_writing.py +18 -39
  30. {sensing → backend/sensing}/face_mesh.py +29 -50
  31. {sensing → backend/sensing}/gaze.py +10 -31
  32. {sensing → backend/sensing}/gesture.py +18 -40
  33. {ui → backend/ui}/app.py +24 -14
  34. frontend/.gitignore +24 -0
  35. frontend/README.md +73 -0
  36. frontend/eslint.config.js +23 -0
  37. frontend/index.html +13 -0
  38. frontend/package.json +31 -0
  39. frontend/pnpm-lock.yaml +1863 -0
  40. frontend/src/App.css +262 -0
  41. frontend/src/App.tsx +133 -0
  42. frontend/src/components/ChatPanel.tsx +116 -0
  43. frontend/src/components/LatencyMetrics.tsx +29 -0
  44. frontend/src/components/PersonaSelector.tsx +42 -0
  45. frontend/src/components/SensingStatus.tsx +49 -0
  46. frontend/src/components/WebcamSensing.tsx +30 -0
  47. frontend/src/hooks/useSensing.ts +164 -0
  48. frontend/src/hooks/useWebcam.ts +102 -0
  49. frontend/src/index.css +111 -0
  50. 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=mlruns
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 run artifacts
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
- main.py / api/main.py / ui/app.py
16
- └── pipeline/graph.py ← LangGraph StateGraph (5 nodes + cond. edges)
17
- ├── pipeline/nodes/intent.py L2 LLM + Pydantic intent routing
18
- ├── pipeline/nodes/retrieval.py L3 — FAISS + BGE retrieval (fast / full)
19
- ├── pipeline/nodes/planner.py L4 expression-conditioned generation
20
- └── pipeline/nodes/feedback.py L5 MLflow logging + Bayesian priors
21
-
22
- sensing/ L1 MediaPipe face mesh, gesture, gaze, air writing
23
- retrieval/ FAISS ops, HDBSCAN clustering, Bayesian bucket priors
24
- generation/ Multi-tier LLM client (vLLM primary / fallback / Ollama local)
25
- guardrails/ Input + output safety checks
26
- config/ Pydantic BaseSettings all config in one place
 
 
 
 
 
 
 
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: rebuild FAISS indexes with BGE embedder
61
- python -m retrieval.vector_store
62
 
63
- # CLI (local Ollama tier, set ACTIVE_LLM_TIER=local in .env)
64
- python main.py --debug
65
 
66
  # Full stack
67
- uvicorn api.main:app --reload # FastAPI on :8000
68
- streamlit run ui/app.py # Streamlit on :8501
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 L5MLflow 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
- Webcam (L1: sensing) → Intent Decomposition (L2) → Retrieval (L3) → Generation (L4) → Feedback (L5)
 
 
 
 
38
  ```
39
 
40
  | Layer | Module | What it does |
41
  |-------|--------|-------------|
42
- | L1 | `sensing/` | MediaPipe face mesh, hand gestures, gaze tracking, air writing |
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 – 3.12** (Python 3.14 has a known Pydantic v1 incompatibility warning — functional but noisy)
 
57
  - [Ollama](https://ollama.com) installed locally for the `local` LLM tier
58
- - A webcam (required for the live sensing layer; optional for CLI mode)
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
- ### 2. Check out the active branch
73
-
74
- ```bash
75
- git checkout akash/v1
76
- ```
77
-
78
- ### 3. Create and activate a virtual environment
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 (set when using cloud tier) |
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
- ### Option A — CLI (simplest, no webcam needed)
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
- python main.py --user mia_chen --tier local
170
  ```
171
 
172
- ### Option B Full stack (FastAPI + Streamlit UI)
 
173
 
174
- Start the API server in one terminal:
175
- ```bash
176
- uvicorn api.main:app --reload --port 8000
177
- ```
178
 
179
- Start the Streamlit frontend in another terminal:
180
  ```bash
181
- streamlit run ui/app.py
 
182
  ```
183
 
184
- Then open [http://localhost:8501](http://localhost:8501) in your browser.
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
- uvicorn api.main:app --reload
 
196
  ```
197
 
198
  Example request:
@@ -208,52 +136,34 @@ curl -X POST http://localhost:8000/chat \
208
 
209
  ```
210
  multimodal_aac_chatbot/
 
 
 
 
 
211
 
212
- ├── config/
213
- ── settings.py # All config via Pydantic BaseSettings
 
 
 
 
 
 
 
 
 
 
214
 
215
  ├── data/
216
- │ ├── generate_users.py # Regenerates persona memories + users.json
217
- │ ├── users.json # Flat user index
218
- ── memories/ # Per-persona memory JSON files
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
- ├── main.py # CLI entry point
254
- ├── requirements.txt # Python dependencies
255
- ├── .env.example # Environment variable template
256
- └── CLAUDE.md # Developer notes (AI assistant context)
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 guardrails.checks import check_input
24
- from pipeline.graph import aac_graph
25
- from pipeline.state import PipelineState
26
- from retrieval.bucket_priors import uniform_priors
 
 
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 # "HAPPY"|"FRUSTRATED"|"NEUTRAL"|"SURPRISED"
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
- with open(settings.users_json) as f:
71
- users = {u["id"]: u for u in json.load(f)["users"]}
 
 
 
 
 
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=None,
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={"t_sensing": 0.0, "t_intent": 0.0, "t_retrieval": 0.0, "t_generation": 0.0, "t_total": 0.0},
 
 
 
 
 
 
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
- with open(settings.users_json) as f:
125
- return json.load(f)
 
 
 
 
 
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"] = result["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 config.settings import settings
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(env_file=".env", env_file_encoding="utf-8", extra="ignore")
 
 
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 # used when affect == FRUSTRATED
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" # vLLM default
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
- # Thinking mode: "off" = plain completion, no thinking whatsoever
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 # responses generated per turn for ranking
60
 
61
  # ── Sensing ───────────────────────────────────────────────────────────────
62
- affect_ema_alpha: float = 0.3 # exponential moving average smoothing
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 # px/frame — stroke end threshold
66
- air_write_end_gap_ms: int = 200 # ms of stillness to end a stroke
67
- conflict_overlap_ms: int = 500 # audio + gesture co-occurrence window
68
 
69
  # ── MLflow ────────────────────────────────────────────────────────────────
70
- mlflow_tracking_uri: str = "mlruns"
71
  mlflow_experiment: str = "aac-chatbot"
72
 
73
- # ── Candidate ranking weights (Eq. 2 in proposal) ─────────────────────────
74
- rank_alpha: float = 0.4 # faithfulness weight
75
- rank_beta: float = 0.3 # style similarity weight
76
- rank_gamma: float = 0.3 # affect-match weight
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.primary_api_key)
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
- return {
57
- "primary": settings.primary_model,
58
  "fallback": settings.fallback_model,
59
- "local": settings.local_model,
60
- }[resolved]
 
 
 
 
 
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
- # "suppress" = actively inject /no_think or vLLM flag for models
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 = {**extra_body, "chat_template_kwargs": {"enable_thinking": False}}
 
 
 
117
 
118
- # When thinking is enabled (strip/full), add budget so the model
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 = "I'm here to help communicate as this person — that's a bit outside what I do."
 
 
41
 
42
 
43
  # ── Public API ─────────────────────────────────────────────────────────────────
44
 
45
- def check_input(query: str) -> dict:
46
- """
47
- Validate the partner's query before retrieval.
48
 
49
- Returns:
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 {"allowed": False, "reason": "empty_query", "fallback": "Could you repeat that?"}
 
 
 
 
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
- # Light hallucination check: if the model asserts specific numbers or
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 {"passed": False, "issue": "unsupported_claim", "fallback": SAFE_FALLBACK}
 
 
 
 
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 ", " was ", " has ", " have ", " lives in ",
92
- " born in ", " works at ", " studied at ",
 
 
 
 
 
 
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 PipelineState, GenerationConfig
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", type=str, default=None, help="Persona user_id")
35
- p.add_argument("--debug", action="store_true", help="Print latency table each turn")
36
- p.add_argument("--fast", action="store_true",
37
- help="Skip LLM intent call — use keyword routing instead (faster local dev)")
38
- p.add_argument("--tier", type=str, default=None,
39
- choices=["primary", "fallback", "local"],
40
- help="Override LLM tier (default: settings.active_llm_tier)")
 
 
 
 
 
 
 
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(w in q for w in ["medication", "medicine", "doctor", "health", "allergic", "therapy"]):
 
 
 
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 = "CONTEXTUAL" if any(w in q for w in ["you just said", "earlier", "you mentioned"]) else "PERSONAL"
 
 
 
 
63
 
64
  route = {
65
- "sub_intents": [{"type": intent_type, "query": query, "bucket_hint": bucket, "priority": "normal"}],
66
- "style_constraints": {"tone_tag": "[TONE:DEFAULT]", "max_tokens": 100,
67
- "retrieval_mode": "full", "persona_mod": "baseline"},
 
 
 
 
 
 
 
 
 
 
 
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(f"Invalid id.")
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", "intent", "retrieval", "generation", "TOTAL"]
103
- vals = [f"{log.get(f, 0):.3f}s" for f in fields]
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) # just for timing reference
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, # pre-filled → intent node sees it and skips LLM call
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={"t_sensing": 0.0, "t_intent": round(t_intent_fast, 4),
184
- "t_retrieval": 0.0, "t_generation": 0.0, "t_total": 0.0},
 
 
 
 
 
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 = result["bucket_priors"]
195
 
196
  if args.debug:
197
  print_latency(result.get("latency_log") or {}, turn_id)
198
- print(f" tier={result.get('llm_tier_used')} | "
199
- f"retrieval={result.get('retrieval_mode_used')} | "
200
- f"affect={(result.get('affect') or {}).get('emotion','?')}\n")
 
 
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
- LangGraph stateful directed graph the five-layer AAC pipeline.
3
 
4
- Topology (see proposal Figure 2):
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", intent.run)
34
  graph.add_node("fast_retrieval", retrieval.run_fast)
35
  graph.add_node("full_retrieval", retrieval.run_full)
36
- graph.add_node("primary_gen", planner.run_primary)
37
- graph.add_node("fallback_gen", planner.run_fallback)
38
- graph.add_node("feedback", feedback.run)
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", "feedback")
64
  graph.add_edge("fallback_gen", "feedback")
65
- graph.add_edge("feedback", END)
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 json
14
- import time
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
- t0 = time.perf_counter()
25
-
26
- mlflow_run_id = _log_to_mlflow(state)
 
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
- "user_id": state["user_id"],
49
- "turn_id": state["turn_id"],
50
- "llm_tier": state.get("llm_tier_used", "unknown"),
51
- "retrieval_mode": state.get("retrieval_mode_used", "unknown"),
52
- "affect": affect,
53
- "guardrail_passed": state.get("guardrail_passed", True),
54
- })
55
- mlflow.log_metrics({
56
- "t_sensing": latency.get("t_sensing", 0.0),
57
- "t_intent": latency.get("t_intent", 0.0),
58
- "t_retrieval": latency.get("t_retrieval", 0.0),
59
- "t_generation": latency.get("t_generation", 0.0),
60
- "t_total": latency.get("t_total", 0.0),
61
- "num_chunks": float(len(state.get("retrieved_chunks") or [])),
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", "content": state["raw_query"]},
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, Optional
13
 
14
  from pydantic import BaseModel
15
- from config.settings import settings
16
- from generation.llm_client import chat_complete
17
- from pipeline.state import PipelineState, GenerationConfig, IntentRoute
 
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: Optional[BucketType] = None
29
  priority: Literal["fast", "normal"] = "normal"
30
 
31
 
32
  class StyleConfig(BaseModel):
33
- tone_tag: str # e.g. "[TONE:WITTY_SARCASTIC]"
34
  max_tokens: int
35
- retrieval_mode: str # "fast" | "full"
36
- persona_mod: str # "amplify_quirks" | "suppress_humor" | "baseline" | "add_confirmation"
 
 
37
 
38
 
39
  class IntentRouteSchema(BaseModel):
@@ -42,7 +39,7 @@ class IntentRouteSchema(BaseModel):
42
  affect: AffectEmotion
43
 
44
 
45
- # ── Affect → generation config mapping (proposal Table 1) ─────────────────────
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(query: str, affect: str, persona_name: str) -> str:
 
 
 
 
 
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 {} # nothing to update — downstream nodes use the pre-filled values
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
- {"role": "user", "content": _build_user_prompt(query, emotion, persona_name)},
 
 
 
 
 
 
 
 
127
  ]
128
  if attempt > 0:
129
- messages.append({"role": "user", "content": f"Validation error: {last_error}. Fix and retry."})
 
 
 
 
 
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": [{"type": "PERSONAL", "query": query, "bucket_hint": None, "priority": "normal"}],
 
 
 
 
 
 
 
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": "[TONE:WITTY_SARCASTIC]",
28
- "FRUSTRATED": "[TONE:DIRECT_EMPATHETIC]",
29
  },
30
  "gerald_okafor": {
31
- "HAPPY": "[TONE:WARM_FORMAL]",
32
- "FRUSTRATED": "[TONE:MEASURED_EMPATHETIC]",
33
  },
34
  "arjun_mehta": {
35
- "HAPPY": "[TONE:DIRECT_WARM]",
36
- "FRUSTRATED": "[TONE:MINIMAL_DIRECT]",
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:] # last 3 turns only
67
 
68
- tone_tag = _resolve_tone_tag(user_id, affect, gen_cfg.get("tone_tag", "[TONE:DEFAULT]"))
69
- prompt = _build_prompt(profile, chunks, history, state["raw_query"], tone_tag, gen_cfg)
 
 
 
 
 
 
 
 
 
 
 
 
 
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) + 256,
76
  temperature=0.7,
77
  tier=tier,
78
  )
79
  candidates.append(text)
80
 
81
- selected = _rank_candidates(candidates, chunks, affect, profile)
 
 
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 = "\n".join(f" [{c['bucket']}] {c['text']}" for c in chunks) or " (no memories retrieved)"
122
- history_block = "\n".join(f" {h.get('role','?')}: {h.get('content','')}" for h in history) or " (start of session)"
 
 
 
 
 
 
123
  style_exemplar = profile.get("style_exemplar", "")
124
 
 
 
 
 
 
 
 
 
 
125
  persona_mod = gen_cfg.get("persona_mod", "baseline")
126
  persona_instruction = {
127
- "amplify_quirks": "Amplify your characteristic style and personality.",
128
- "suppress_humor": "Be direct and supportive. Suppress humor.",
129
- "baseline": "Use your natural communication style.",
130
- "add_confirmation": "Add a clarifying question or confirmation at the end.",
131
  }.get(persona_mod, "Use your natural communication style.")
132
 
133
  return f"""\
134
- You are {profile['name']}, an AAC device user with {profile['condition']}.
135
- Communication style: {profile['style']}
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['name']}.
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": ["great", "love", "enjoy", "happy", "fun"],
179
  "FRUSTRATED": ["okay", "fine", "sure", "yes", "no"],
180
- "NEUTRAL": [],
181
  "SURPRISED": ["really", "oh", "interesting", "wow"],
182
  }
183
- affect_words = set(affect_positive_map.get(affect, []))
 
 
 
 
 
 
 
 
184
 
185
  def score(c: str) -> float:
186
  words = set(c.lower().split())
187
- faithful = len(words & evidence_words) / max(len(words), 1)
188
- style_sim = len(words & style_words) / max(len(words), 1)
189
- affect_m = len(words & affect_words) / max(len(words), 1)
190
  return (
191
  settings.rank_alpha * faithful
192
- + settings.rank_beta * style_sim
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
- bucket_hint = _top_prior_bucket(state["bucket_priors"])
 
 
 
 
 
 
 
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
- state.get("gaze_bucket")
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 # Mouth Aspect Ratio
18
- EAR: float # Eye Aspect Ratio
19
- BRI: float # Brow Raise Index
20
- LCP: float # Lip Corner Pull
21
 
22
 
23
  class AffectState(TypedDict):
24
- emotion: str # "HAPPY" | "FRUSTRATED" | "NEUTRAL" | "SURPRISED"
25
  vector: AffectVector
26
  smoothed: AffectVector # EMA-smoothed vector
27
 
28
 
29
  class RetrievedChunk(TypedDict):
30
  text: str
31
- bucket: str # family | medical | hobbies | daily_routine | social
32
  user: str
33
- score: float # cross-encoder rerank score
34
 
35
 
36
  class SubIntent(TypedDict):
37
- type: str # "PERSONAL" | "CONTEXTUAL" | "OPEN_DOMAIN"
38
  query: str
39
- bucket_hint: Optional[str]
40
- priority: str # "fast" | "normal"
41
 
42
 
43
  class IntentRoute(TypedDict):
44
  sub_intents: list[SubIntent]
45
- style_constraints: dict[str, Any] # tone, max_tokens, etc.
46
  affect: str
47
 
48
 
49
  class GenerationConfig(TypedDict):
50
  max_tokens: int
51
- tone_tag: str # e.g. "[TONE:WITTY_SARCASTIC]"
52
- retrieval_mode: str # "fast" | "full"
53
- persona_mod: str # "amplify_quirks" | "suppress_humor" | "baseline" | "add_confirmation"
 
 
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] # full profile from users.json
70
  session_history: Annotated[list[dict], operator.add] # auto-appended
71
  turn_id: int
72
 
73
  # ── L1: Sensing outputs ───────────────────────────────────────────────────
74
- affect: Optional[AffectState]
75
- gesture_tag: Optional[str] # e.g. "THUMBS_UP"
76
- gaze_bucket: Optional[str] # bucket hinted by gaze fixation
77
- air_written_text: Optional[str] # concatenated air-written chars
78
 
79
  # ── L2: Intent decomposition outputs ─────────────────────────────────────
80
- raw_query: str # partner's typed/spoken query
81
- intent_route: Optional[IntentRoute] # Pydantic-validated routing
82
- generation_config: Optional[GenerationConfig]
83
 
84
  # ── L3: Retrieval outputs ─────────────────────────────────────────────────
85
  retrieved_chunks: list[RetrievedChunk]
86
- bucket_priors: dict[str, float] # session-level Bayesian priors
87
- retrieval_mode_used: str # "fast" | "full"
88
 
89
  # ── L4: Generation outputs ────────────────────────────────────────────────
90
- augmented_prompt: Optional[str]
91
- candidates: list[str] # 2-3 candidate responses
92
- selected_response: Optional[str]
93
- llm_tier_used: str # "primary" | "fallback" | "local"
94
 
95
  # ── L5: Feedback / tracking ───────────────────────────────────────────────
96
- latency_log: Optional[LatencyLog]
97
- mlflow_run_id: Optional[str]
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
- Embed all memory chunks for a persona and cluster with HDBSCAN.
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, true_bucket in zip(texts, labels, true_buckets):
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
- Compare HDBSCAN cluster assignments against hand-authored bucket labels.
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() -> SentenceTransformer:
 
 
27
  return SentenceTransformer(settings.embed_model)
28
 
29
 
30
  @lru_cache(maxsize=1)
31
- def _get_reranker() -> CrossEncoder:
 
 
32
  return CrossEncoder(settings.rerank_model)
33
 
34
 
 
 
 
 
 
 
 
35
  # ── Index cache (one FAISS index per user_id) ─────────────────────────────────
36
 
37
- _index_cache: dict[str, tuple[faiss.Index, list[dict]]] = {}
38
 
39
 
40
- def load_index(user_id: str) -> tuple[faiss.Index, list[dict]]:
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
- t0 = time.perf_counter()
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 # fallback: all buckets
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(text=c["text"], bucket=c["bucket"], user=c["user"], score=float(s))
 
 
102
  for s, c in ranked[:rerank_k]
103
  ]
104
  else:
105
  top = [
106
- RetrievedChunk(text=c["text"], bucket=c["bucket"], user=c["user"], score=1.0)
 
 
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
- def build_index(persona_path: str | Path) -> tuple[faiss.Index, list[dict]]:
119
- """Embed all memory chunks for a persona and build a FAISS IndexFlatIP."""
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: faiss.Index, meta: list[dict], save_dir: str | Path) -> None:
141
  p = Path(save_dir)
142
  p.mkdir(parents=True, exist_ok=True)
143
- faiss.write_index(index, str(p / "index.faiss"))
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
- try:
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
- if not _MP_AVAILABLE:
52
- raise ImportError("mediapipe is required: pip install mediapipe")
 
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 = settings.air_write_velocity_end
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
- np.interp(t_new, t_old, pts[:, 0]),
146
- np.interp(t_new, t_old, pts[:, 1]),
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 # filename = character label
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
- try:
33
- import cv2
34
- _CV2_AVAILABLE = True
35
- except ImportError:
36
- _CV2_AVAILABLE = False
37
 
38
 
39
- # ── MediaPipe landmark indices (from proposal §5.2) ───────────────────────────
40
 
41
  # MAR — mouth vertical / horizontal ratio
42
- _MOUTH_TOP = 13
43
  _MOUTH_BOTTOM = 14
44
- _MOUTH_LEFT = 61
45
- _MOUTH_RIGHT = 291
46
 
47
  # EAR — eye vertical / horizontal ratio (right eye)
48
- _EYE_TOP = 159
49
  _EYE_BOTTOM = 145
50
- _EYE_LEFT = 33
51
- _EYE_RIGHT = 133
52
 
53
  # BRI — brow vertical displacement relative to eye centre
54
- _BROW_LEFT = 70
55
  _BROW_RIGHT = 300
56
 
57
  # LCP — mouth corner horizontal displacement from neutral baseline
58
- _CORNER_LEFT = 61
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
- _smoothed: AffectVector = field(default_factory=lambda: AffectVector(MAR=0.0, EAR=0.3, BRI=0.0, LCP=0.0))
74
- _neutral_lcp: float = 0.0 # calibrated at session start
 
 
 
75
  _calibrated: bool = False
76
 
77
  def __post_init__(self):
78
- if not _MP_AVAILABLE:
79
- raise ImportError("mediapipe is required: pip install mediapipe")
80
- if not _CV2_AVAILABLE:
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, # enables iris landmarks (468-477)
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 {"MAR": float(MAR), "EAR": float(EAR), "BRI": float(BRI), "LCP": float(LCP)}
 
 
 
 
 
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
- import numpy as np
22
 
23
- from config.settings import settings
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 = 468
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
- if not _MP_AVAILABLE:
59
- raise ImportError("mediapipe is required: pip install mediapipe")
 
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
- try:
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": "[GESTURE:THUMBS_UP][TONE:AFFIRMATIVE]",
29
  "THUMBS_DOWN": "[GESTURE:THUMBS_DOWN][TONE:NEGATIVE]",
30
- "POINTING": "[GESTURE:POINTING][INTENT:REFERENTIAL]",
31
- "WAVING": "[GESTURE:WAVING][INTENT:GREETING]",
32
  }
33
 
34
 
@@ -39,8 +22,9 @@ class GestureClassifier:
39
  """
40
 
41
  def __init__(self):
42
- if not _MP_AVAILABLE:
43
- raise ImportError("mediapipe is required: pip install mediapipe")
 
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 = p[4]
86
- index_tip = p[8]
87
- middle_tip = p[12]
88
- ring_tip = p[16]
89
- pinky_tip = p[20]
90
- index_mcp = p[5] # knuckle
91
 
92
  # THUMBS_UP: thumb tip above wrist, other fingers curled
93
  fingers_curled = all(
94
- np.linalg.norm(tip) < np.linalg.norm(p[mcp])
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 = all(
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 json
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("Select persona", options=list(user_options.keys()),
56
- format_func=lambda k: user_options.get(k, k))
 
 
 
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 = None if affect_choice == "Auto (webcam)" else affect_choice
 
 
77
 
78
  st.divider()
79
 
80
  # Live affect indicator
81
  st.subheader("Detected Affect")
82
  affect_emoji = {
83
- "HAPPY": "😊", "FRUSTRATED": "😤",
84
- "NEUTRAL": "😐", "SURPRISED": "😲",
 
 
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("Live webcam sensing runs in the sensing client.\nAffect is sent to the API automatically.")
 
 
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({"role": "aac_user", "content": response_text})
 
 
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", "Sensing"),
145
- ("t_intent", "Intent"),
146
- ("t_retrieval", "Retrieval"),
147
  ("t_generation", "Generation"),
148
- ("t_total", "**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
+ }