saivivek6 commited on
Commit
0b491ba
·
1 Parent(s): 198f9ee

Update anupa: iframe mini-app widgets

Browse files
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ COPY --chown=user ./anupa/requirements.txt /app/requirements.txt
10
+ RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
11
+
12
+ COPY --chown=user ./anupa /app
13
+
14
+ ENV PORT=7860
15
+ EXPOSE 7860
16
+
17
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,14 @@
1
- ---
2
- title: AdaptiveUI UX
3
- emoji: 🦀
4
  colorFrom: indigo
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
+ ---
2
+ title: AdaptiveUI (anupa)
3
+ emoji: "🧠"
4
  colorFrom: indigo
5
+ colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ # AdaptiveUI (anupa)
11
+
12
+ Claude-style single-call response + widget HTML rendered in an iframe.
13
+
14
+ This Space runs the `anupa` server on port 7860.
anupa/.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
anupa/.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+
5
+ # Distribution / packaging
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .eggs/
10
+
11
+ # Environments
12
+ .env
13
+ .venv
14
+ env/
15
+ venv/
16
+
17
+ # Testing
18
+ htmlcov/
19
+ .tox/
20
+ .pytest_cache/
21
+ .coverage
22
+
23
+ # IDEs
24
+ .vscode/
25
+ .idea/
26
+
27
+ # OS Specific
28
+ .DS_Store
29
+ Thumbs.db
anupa/Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+
7
+ COPY . .
8
+
9
+ # Hugging Face Spaces expects port 7860 by default
10
+ ENV PORT=7860
11
+ EXPOSE 7860
12
+
13
+ CMD ["python", "app.py"]
anupa/README.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ sdk: docker
3
+ app_port: 7860
4
+ ---
5
+ # Adaptive Presentation Engine — Demo
6
+
7
+ A web demo of the Contextual Hierarchical Bayesian Architecture pipeline.
8
+
9
+ ## What it shows
10
+ - **Live strategy selection** via Thompson Sampling over the Bayesian posterior
11
+ - **Posterior updating in real-time** as you rate responses (👍 / 👎)
12
+ - **Feature vector** used for each inference
13
+ - **Per-strategy expected reward** estimates that evolve with each interaction
14
+
15
+ ---
16
+
17
+ ## Setup (5 minutes)
18
+
19
+ ### 1. Install Python dependencies
20
+ ```bash
21
+ pip install numpy
22
+ # (flask is not needed — the server is built-in)
23
+ ```
24
+
25
+ ### 2. Configure model (optional)
26
+ This app supports:
27
+ - OpenAI-compatible providers (via `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL`)
28
+ - Anthropic Claude (via `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`)
29
+
30
+ ### 3. Run the demo server
31
+ ```bash
32
+ python app.py
33
+ ```
34
+
35
+ ### 4. Open in browser
36
+ ```
37
+ http://localhost:5000
38
+ ```
39
+
40
+ ---
41
+
42
+ ## How to demo it
43
+
44
+ 1. **Send a few messages** — watch the strategy get selected in the sidebar
45
+ 2. **Rate the responses** with 👍 or 👎 — watch the posterior bars update live
46
+ 3. **Send different message types** (short vs long, questions vs statements) — the feature vector changes
47
+ 4. **After 5-10 interactions**, the engine starts preferring strategies that got positive rewards
48
+ 5. **Reset session** to show the system starting fresh from the global prior
49
+
50
+ ---
51
+
52
+ ## Pipeline stages shown
53
+ | Stage | What the demo shows |
54
+ |---|---|
55
+ | Feature extraction | Feature vector panel (x ∈ ℝ⁸) |
56
+ | Thompson Sampling | Expected reward % per strategy |
57
+ | LLM rendering | Live response with strategy label |
58
+ | Reward observation | 👍/👎 buttons |
59
+ | Posterior update | Bar charts animate in real-time |
60
+
61
+ ---
62
+
63
+ ## Architecture notes (for Q&A)
64
+ - **No JSON files** — posterior stored in-memory (Redis in production)
65
+ - **Hierarchical prior** — new users inherit global posterior
66
+ - **Exponential decay** — old observations lose weight over time (γ=0.99)
67
+ - **Global update** — each interaction slightly updates the shared prior (α=0.05)
68
+ - **Circuit breaker** — LLM timeouts fail fast gracefully
anupa/UpdatedReadme.md ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Anupa: Adaptive Presentation Engine — Complete Reference
2
+
3
+ A professional, modular backend demonstrating Bayesian strategy selection and real-time posterior updating. This app learns user preferences and adapts its response format (bullet points, prose, questions, etc.) based on observed rewards.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+ - [Project Structure](#project-structure)
9
+ - [File Manifest](#file-manifest)
10
+ - [System Architecture](#system-architecture)
11
+ - [How the App Works](#how-the-app-works)
12
+ - [Setup & Running](#setup--running)
13
+ - [Configuration](#configuration)
14
+ - [API Reference](#api-reference)
15
+
16
+ ---
17
+
18
+ ## Project Structure
19
+
20
+ ```
21
+ backend/
22
+ ├── __init__.py # Package entrypoint
23
+ ├── config.py # Environment + constants
24
+ ├── utils.py # Math, heuristics, post-processing
25
+ ├── llm.py # LLM backends (Anthropic, OpenAI-compatible)
26
+ ├── engine.py # Bayesian learner
27
+ └── server.py # HTTP handlers + runner
28
+
29
+ Root folder
30
+ ├── app.py # Launcher (imports backend.server.run_server)
31
+ ├── index.html # Frontend demo UI
32
+ ├── Dockerfile # Container image
33
+ ├── requirements.txt # Python dependencies
34
+ ├── README.md # Original readme (kept for reference)
35
+ └── UpdatedReadme.md # This file
36
+ ```
37
+
38
+ ---
39
+
40
+ ## File Manifest
41
+
42
+ ### `backend/__init__.py`
43
+ **Purpose:** Package initialization and public API.
44
+
45
+ Exposes `run_server()` so callers only need:
46
+ ```python
47
+ from backend import run_server
48
+ run_server()
49
+ ```
50
+
51
+ ---
52
+
53
+ ### `backend/config.py`
54
+ **Purpose:** Centralized configuration and constants.
55
+
56
+ **Key variables:**
57
+ - `LLM_MODE` — selects backend: `"openai_compat"` or `"anthropic"`
58
+ - `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL` — remote OpenAI-compatible API (Groq)
59
+ - `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` — Anthropic Claude API configuration
60
+ - `D=10` — feature vector dimensionality
61
+ - `LAMBDA`, `GAMMA`, `ALPHA_G`, `TS_TEMPERATURE` — Bayesian hyperparameters
62
+ - `STRATEGIES` — dict of 5 response primitives (bulleted, narrative, concise, Socratic, step-by-step)
63
+ - `HERE`, `INDEX_HTML` — paths for serving the frontend
64
+
65
+ **Usage:** Other modules import from this single source of truth.
66
+
67
+ ---
68
+
69
+ ### `backend/utils.py`
70
+ **Purpose:** Small, reusable utilities.
71
+
72
+ **Functions:**
73
+ - `sigmoid(x)` — numerically stable logistic function
74
+ - `mean_uncertainty(sigma_inv)` — summarize posterior variance from precision matrix
75
+ - `fast_valence(message, prev_response)` — lightweight regex-based sentiment heuristic; returns `{"pos", "neg", "reason"}`
76
+ - `enforce_response(strategy, text)` — post-process LLM output to match the chosen format (strips questions, forces bullets/numbers, caps sentence counts)
77
+
78
+ **Helpers (module-level):**
79
+ - `_POS`, `_NEG`, `_REPHRASE` — regex patterns for auto-reward detection
80
+
81
+ **Usage:** Called by server during chat turns and posterior updates.
82
+
83
+ ---
84
+
85
+ ### `backend/llm.py`
86
+ **Purpose:** LLM API wrappers for Anthropic and OpenAI-compatible endpoints.
87
+
88
+ **Public functions:**
89
+ - `call_openai_compat(prompt, system, timeout=120)` — POST to OpenAI-compatible endpoint; returns `(text, elapsed_sec, mode)`
90
+ - `call_anthropic(prompt, system, timeout=120)` — call Anthropic Messages API; returns `(text, elapsed_sec, mode)`
91
+ - `openai_health(timeout=10)` — check OpenAI-compatible endpoint and available models
92
+ - `anthropic_health()` — lightweight Anthropic config health summary
93
+
94
+ **Internal helpers:**
95
+ - `_post_json_url()`, `_get_json_url()` — raw HTTP wrappers
96
+
97
+ **Usage:** Server calls these during `/api/chat` to fetch responses from the LLM.
98
+
99
+ ---
100
+
101
+ ### `backend/engine.py`
102
+ **Purpose:** The core Bayesian learner for strategy selection and posterior updating.
103
+
104
+ **Class: `BayesianEngine`**
105
+ - `__init__()` — initialize global and per-user posterior means (`mu`) and precision matrices (`sigma_inv`)
106
+ - `get_user(uid)` — fetch or create user state
107
+ - `featurize(message, user)` — convert message + history into a fixed-length feature vector (10-dim)
108
+ - `select(uid, message)` — use Thompson Sampling to pick a strategy; return `(strategy, scores, x)`
109
+ - `update(uid, strategy, x, reward)` — Bayesian update for user and global posterior
110
+ - `apply_preferences(uid, strategy_names)` — apply soft bias or hard-lock if user selects one strategy
111
+ - `posterior_summary()`, `user_posterior()`, `global_posterior()` — compute compact summaries of expected reward + uncertainty
112
+
113
+ **Singletons:**
114
+ - `engine` — global instance used by the server
115
+ - `USERB_ID` — reserved user ID for a secondary reference posterior (demo artifact)
116
+
117
+ **Bayesian model:** Online logistic regression with exponential decay (γ=0.99) and per-strategy Gaussian posteriors.
118
+
119
+ **Usage:** Server calls during `/api/chat`, `/api/reward`, `/api/preference` endpoints.
120
+
121
+ ---
122
+
123
+ ### `backend/server.py`
124
+ **Purpose:** HTTP server and request handlers.
125
+
126
+ **Class: `Handler(BaseHTTPRequestHandler)`**
127
+ Handles:
128
+ - `GET /` — serve `index.html` frontend
129
+ - `GET /api/health` — return LLM backend status
130
+ - `GET /api/state` — return user's current posterior and global stats
131
+ - `POST /api/chat` — accept user message, auto-reward previous turn, select strategy, call LLM, enforce format, persist state
132
+ - `POST /api/reward` — accept explicit user reward
133
+ - `POST /api/preference` — set user strategy preferences
134
+ - `POST /api/reset` — reset user state
135
+ - `OPTIONS *` — handle CORS preflight
136
+
137
+ **Private helpers:**
138
+ - `_json()` — JSON response with CORS headers
139
+ - `_html()` — serve frontend file
140
+ - `_body()` — parse JSON request body
141
+ - `_cors()` — set CORS headers
142
+ - `log_message()` — suppress access logs for cleanliness
143
+
144
+ **Function: `run_server()`**
145
+ - Prints startup banner
146
+ - Performs lightweight health checks on Anthropic/OpenAI endpoint
147
+ - Starts `ThreadedServer` on port 5051 (configurable via `PORT` env var)
148
+
149
+ **Class: `ThreadedServer(ThreadingMixIn, HTTPServer)`**
150
+ - Allows concurrent request handling
151
+
152
+ **Usage:** Imported and called by `app.py`.
153
+
154
+ ---
155
+
156
+ ### `app.py` (Root)
157
+ **Purpose:** Lightweight launcher script.
158
+
159
+ Simply imports and calls:
160
+ ```python
161
+ from backend.server import run_server
162
+
163
+ if __name__ == "__main__":
164
+ run_server()
165
+ ```
166
+
167
+ This keeps a familiar entrypoint (`python app.py`) while the implementation lives in the package.
168
+
169
+ ---
170
+
171
+ ### `index.html`
172
+ **Purpose:** Frontend React/Preact demo.
173
+
174
+ Communicates with backend via:
175
+ - `GET /api/health` — check LLM status on load
176
+ - `GET /api/state` — fetch user posteriors
177
+ - `POST /api/chat` — send message, receive response + Bayesian state
178
+ - `POST /api/reward` — send user feedback
179
+ - `POST /api/preference` — set strategy lock
180
+ - `POST /api/reset` — reset session
181
+
182
+ Displays:
183
+ - Strategy label and instruction
184
+ - Expected reward scores per strategy
185
+ - Feature vector (x ∈ ℝ¹⁰)
186
+ - Posterior bar charts (mean + uncertainty)
187
+ - Auto-detected valence reason
188
+
189
+ ---
190
+
191
+ ### `Dockerfile`
192
+ **Purpose:** Container build for deployment.
193
+
194
+ Installs Python, dependencies, and runs `python app.py`.
195
+
196
+ ---
197
+
198
+ ### `requirements.txt`
199
+ **Purpose:** Python package dependencies.
200
+
201
+ Currently:
202
+ - `numpy` — linear algebra for Bayesian updates
203
+ - `python-dotenv` — load `.env` for API keys
204
+
205
+ ---
206
+
207
+ ## System Architecture
208
+
209
+ ### High-Level Component Diagram
210
+
211
+ ```
212
+ ┌─────────────────────────────────────────────────────────────────────┐
213
+ │ Frontend (index.html) │
214
+ │ Browser UI → POST /api/chat, GET /api/state, etc. │
215
+ └────────────────────────┬────────────────────────────────────────────┘
216
+
217
+
218
+ ┌─────────────────────────────────────────────────────────────────────┐
219
+ │ app.py (Launcher) │
220
+ │ from backend.server import run_server() │
221
+ └────────────────────────┬────────────────────────────────────────────┘
222
+
223
+
224
+ ┌─────────────────────────────────────────────────────────────────────┐
225
+ │ HTTP Server + Handler (backend/server.py) │
226
+ │ ├─ GET /api/health → check LLM backend │
227
+ │ ├─ GET /api/state → fetch posteriors │
228
+ │ ├─ POST /api/chat → process user message │
229
+ │ ├─ POST /api/reward → apply explicit reward │
230
+ │ ├─ POST /api/preference→ lock strategy │
231
+ │ └─ POST /api/reset → clear user data │
232
+ └────────────────────────┬─────────┬──────────────┬──────────────────┘
233
+ │ │ │
234
+ ┌───────────────┘ │ └──────────────┐
235
+ │ │ │
236
+ ▼ ▼ ▼
237
+ ┌──────────────┐ ┌───────────���─┐ ┌────────────────┐
238
+ │ Bayesian │ │ LLM Backends│ │ Utils (Heuristics
239
+ │ Engine │ │ (backend/llm) │ │ & Post-process)
240
+ │ (backend/ │ │ ┌─ Anthropic │ │ ├─ sigmoid()
241
+ │ engine.py) │ │ └─ OpenAI │ │ ├─ fast_valence()
242
+ │ │ │ │ │ └─ enforce_response()
243
+ │ • select() │◄─────────┤ • call_*() │ └────────────────┘
244
+ │ • update() │ │ • health() │
245
+ │ • featurize()│ │ • format_*()│
246
+ │ • apply_pref│ │ │
247
+ └──────────────┘ └─────────────┘
248
+
249
+ │ (reads hyperparams + strategy list)
250
+
251
+
252
+ ┌──────────────────────────────────────────┐
253
+ │ Config (backend/config.py) │
254
+ │ ├─ LLM_MODE, OPENAI_*, ANTHROPIC_* │
255
+ │ ├─ D, LAMBDA, GAMMA, ALPHA_G, TS_TEMP │
256
+ │ ├─ STRATEGIES dict + STRATEGY_NAMES │
257
+ │ └─ HERE, INDEX_HTML paths │
258
+ └──────────────────────────────────────────┘
259
+ ```
260
+
261
+ ---
262
+
263
+ ## How the App Works
264
+
265
+ ### 1. **Initialization**
266
+ - User loads `http://localhost:5051` → frontend fetches `/api/health` and `/api/state`
267
+ - Server initializes or fetches user state (in-memory dict keyed by `uid`)
268
+ - User inherits global posterior (hierarchical Bayesian prior)
269
+
270
+ ### 2. **User Sends a Message**
271
+ ```
272
+ User message
273
+
274
+ POST /api/chat {uid, message}
275
+
276
+ [Server] fast_valence(message, prev_response)
277
+ ├─ Auto-reward previous turn (if exists)
278
+ └─ Call engine.update(uid, strategy, x, reward)
279
+
280
+ [Server] engine.select(uid, message)
281
+ ├─ featurize(message, user) → x ∈ ℝ¹⁰
282
+ ├─ Thompson Sampling per strategy
283
+ └─ Return best strategy + scores
284
+
285
+ [Server] Build system prompt with FORMAT RULE
286
+ ├─ Include selected strategy instruction
287
+ ├─ Add recent conversation history
288
+ └─ Send to LLM
289
+
290
+ [LLM] Generate response (single-call)
291
+ └─ Return text + optional widget HTML
292
+
293
+ [Server] enforce_response(strategy, text)
294
+ ├─ Strip unwanted questions
295
+ ├─ Force format (bullets, numbers, etc.)
296
+ └─ Return polished response
297
+
298
+ [Server] Persist conversation + state
299
+ └─ Update user["history"], ["last_response"], ["last_x"]
300
+
301
+ [Server] Return JSON response
302
+ ├─ response text + strategy label
303
+ ├─ instruction (what the system told the LLM)
304
+ ├─ scores (expected reward per strategy)
305
+ ├─ x_vec (feature vector used)
306
+ ├─ posteriors (updated beliefs)
307
+ ├─ auto_detected + auto_r (heuristic reward)
308
+ └─ auto_reason (why the heuristic fired)
309
+
310
+ [Frontend] Display response
311
+ ├─ Show strategy + instruction
312
+ ├─ Show bar charts for expected reward
313
+ ├─ Show feature vector
314
+ └─ Display 👍/👎 buttons
315
+ ```
316
+
317
+ ### 3. **User Rates Response**
318
+ ```
319
+ User clicks 👍 or 👎
320
+
321
+ POST /api/reward {uid, strategy, x_vec, reward}
322
+
323
+ [Server] engine.update(uid, strategy, x, reward)
324
+ ├─ Update user["mu"][strategy] via logistic regression
325
+ ├─ Update user["sigma_inv"][strategy] (precision matrix)
326
+ ├─ Also update global posterior (with weight α=0.05)
327
+ └─ Append to user["reward_log"]
328
+
329
+ [Server] Recompute posteriors
330
+ └─ Return new bar chart data
331
+
332
+ [Frontend] Animate bar chart updates
333
+ └─ Show how beliefs changed
334
+ ```
335
+
336
+ ### 4. **Posterior Update (Bayesian Mechanics)**
337
+ The engine maintains per-user **Gaussian posteriors** β ~ N(μ, Σ) for each strategy.
338
+
339
+ **Model:** Logistic regression where reward r̂ = sigmoid(x^T β)
340
+
341
+ **Update rule:**
342
+ - r̂_old = sigmoid(x^T μ_old)
343
+ - Σ_new = Σ_old^{-1} + x x^T · w + λ I (w = r̂(1 - r̂))
344
+ - μ_new = μ_old + Σ_new^{-1} x (r - r̂_old)
345
+
346
+ **Global posterior** gets a small, weighted update (α=0.05) so all users benefit from collective learning.
347
+
348
+ **Exponential decay** (γ=0.99) biases the engine toward recent history.
349
+
350
+ ---
351
+
352
+ ## Setup & Running
353
+
354
+ ### 1. Install Python 3.8+
355
+ ```bash
356
+ python3 --version
357
+ ```
358
+
359
+ ### 2. Clone/download the repo
360
+ ```bash
361
+ cd backend
362
+ ```
363
+
364
+ ### 3. Create a virtual environment (recommended)
365
+ ```bash
366
+ python3 -m venv venv
367
+ source venv/bin/activate # On Windows: venv\Scripts\activate
368
+ ```
369
+
370
+ ### 4. Install dependencies
371
+ ```bash
372
+ pip install -r requirements.txt
373
+ ```
374
+
375
+ ### 5. LLM backend
376
+ This repository supports OpenAI-compatible providers and Anthropic Claude.
377
+
378
+ ### 6. Run the app
379
+ ```bash
380
+ python app.py
381
+ ```
382
+
383
+ You should see a startup banner and then the server will be live at `http://localhost:5051`.
384
+
385
+ ### 7. Open browser
386
+ ```
387
+ http://localhost:5051
388
+ ```
389
+
390
+ ---
391
+
392
+ ## Configuration
393
+
394
+ ### Environment Variables
395
+
396
+ Create a `.env` file in the root folder:
397
+
398
+ ```env
399
+ # Backend selection
400
+ LLM_MODE=openai_compat # or "anthropic"
401
+
402
+ # OpenAI-compatible (Groq, etc.)
403
+ OPENAI_BASE_URL=https://api.groq.com/openai/v1
404
+ OPENAI_API_KEY=your_groq_api_key_here
405
+ OPENAI_MODEL=llama-3.1-8b-instant
406
+
407
+ # Anthropic (Claude)
408
+ ANTHROPIC_API_KEY=your_anthropic_key_here
409
+ ANTHROPIC_MODEL=claude-opus-4-6
410
+
411
+ # Port
412
+ PORT=5051
413
+ ```
414
+
415
+ ### Bayesian Hyperparameters
416
+
417
+ Edit `backend/config.py`:
418
+
419
+ ```python
420
+ D = 10 # Feature vector dimension
421
+ LAMBDA = 0.01 # L2 regularization on posteriors
422
+ GAMMA = 0.99 # Exponential decay (prefer recent history)
423
+ ALPHA_G = 0.05 # Global posterior update weight
424
+ TS_TEMPERATURE = 2.0 # Thompson Sampling variance scale (exploration)
425
+ ```
426
+
427
+ ---
428
+
429
+ ## API Reference
430
+
431
+ ### `GET /api/health`
432
+ **Returns:** LLM backend status.
433
+
434
+ **Response:**
435
+ ```json
436
+ {
437
+ "server": "ok",
438
+ "mode": "openai_compat",
439
+ "openai_base_url": "https://api.groq.com/openai/v1",
440
+ "model": "llama-3.1-8b-instant",
441
+ "ok": true,
442
+ "reachable": true,
443
+ "models": ["llama-3.1-8b-instant", "mixtral-8x7b-32768"]
444
+ }
445
+ ```
446
+
447
+ ---
448
+
449
+ ### `GET /api/state?uid=demo`
450
+ **Returns:** Current user state and posteriors.
451
+
452
+ **Response:**
453
+ ```json
454
+ {
455
+ "posterior": {
456
+ "structured_bullets": {"r": 0.52, "u": 0.34},
457
+ "narrative_prose": {"r": 0.48, "u": 0.35},
458
+ ...
459
+ },
460
+ "global": {...},
461
+ "userb": {...},
462
+ "global_n": 42,
463
+ "n_users": 3,
464
+ "msg_count": 5
465
+ }
466
+ ```
467
+
468
+ ---
469
+
470
+ ### `POST /api/chat`
471
+ **Body:**
472
+ ```json
473
+ {
474
+ "uid": "demo",
475
+ "message": "How do I make pasta?"
476
+ }
477
+ ```
478
+
479
+ **Response:**
480
+ ```json
481
+ {
482
+ "response": "- Cook 1 liter of water\n- Add salt\n- ...",
483
+ "strategy": "step_by_step",
484
+ "instruction": "Numbered list of 3-6 steps only.",
485
+ "elapsed": 2.3,
486
+ "llm_mode": "anthropic",
487
+ "scores": {
488
+ "structured_bullets": 0.54,
489
+ "narrative_prose": 0.48,
490
+ ...
491
+ },
492
+ "x_vec": [0.12, 0.34, ...],
493
+ "posterior": {...},
494
+ "global": {...},
495
+ "auto_detected": true,
496
+ "auto_r": 0.75,
497
+ "auto_reason": "positive signal(s)"
498
+ }
499
+ ```
500
+
501
+ ---
502
+
503
+ ### `POST /api/reward`
504
+ **Body:**
505
+ ```json
506
+ {
507
+ "uid": "demo",
508
+ "strategy": "step_by_step",
509
+ "x_vec": [0.12, 0.34, ...],
510
+ "reward": 0.9
511
+ }
512
+ ```
513
+
514
+ **Response:**
515
+ ```json
516
+ {
517
+ "posterior": {...},
518
+ "global": {...},
519
+ "global_n": 43
520
+ }
521
+ ```
522
+
523
+ ---
524
+
525
+ ### `POST /api/preference`
526
+ **Body:**
527
+ ```json
528
+ {
529
+ "uid": "demo",
530
+ "strategies": ["structured_bullets"]
531
+ }
532
+ ```
533
+
534
+ **Response:**
535
+ ```json
536
+ {
537
+ "posterior": {...}
538
+ }
539
+ ```
540
+
541
+ ---
542
+
543
+ ### `POST /api/reset`
544
+ **Body:**
545
+ ```json
546
+ {
547
+ "uid": "demo"
548
+ }
549
+ ```
550
+
551
+ **Response:**
552
+ ```json
553
+ {
554
+ "ok": true
555
+ }
556
+ ```
557
+
558
+ ---
559
+
560
+ ## Troubleshooting
561
+
562
+ ### Server won't start
563
+ - Ensure port 5051 is available: `lsof -i :5051`
564
+ - Check Python version: `python --version` (need 3.8+)
565
+ - Verify dependencies: `pip install -r requirements.txt`
566
+
567
+ ### LLM returns empty responses
568
+ - If using OpenAI-compatible: verify API key in `.env`
569
+ - If using Anthropic: verify `ANTHROPIC_API_KEY` and `ANTHROPIC_MODEL`
570
+ - Check model name matches `OPENAI_MODEL` / `ANTHROPIC_MODEL`
571
+
572
+ ### Feature vector or posterior looks weird
573
+ - This is expected! Bayesian posteriors start with high uncertainty.
574
+ - Send a few more messages and rate them — posteriors will stabilize.
575
+
576
+ ### Valence heuristic seems wrong
577
+ - The regex patterns in `utils.py` are intentionally simple.
578
+ - For production, replace with a small classifier model.
579
+
580
+ ---
581
+
582
+ ## License & Attribution
583
+
584
+ Built as a demonstration of hierarchical Bayesian architecture. Feel free to adapt for your use case.
585
+
586
+ For questions or contributions, reach out to the team.
anupa/app.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point.
2
+
3
+ Run:
4
+ pip install -r requirements.txt
5
+ python app.py
6
+ open http://localhost:5051
7
+ """
8
+
9
+ from backend.server import run_server
10
+
11
+
12
+ if __name__ == "__main__":
13
+ run_server()
anupa/backend/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend package for the Adaptive Presentation Engine.
2
+
3
+ This package contains core modules: configuration, LLM helpers, Bayesian
4
+ engine, utilities, and the HTTP server runner. All internal logic is
5
+ organized here for clean separation from the launcher (app.py).
6
+ """
7
+
8
+ from .server import run_server
9
+
10
+ __all__ = ["run_server"]
anupa/backend/combined_prompt.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-call combined response + widget generation — Claude-style architecture.
2
+
3
+ Instead of two sequential LLM calls (response then widget), this module
4
+ lets GPT-4 generate BOTH in one pass:
5
+
6
+ Output format:
7
+ <RESPONSE>
8
+ [answer text here — follows primitive format rule]
9
+ </RESPONSE>
10
+ <WIDGET>
11
+ [either complete self-contained HTML OR JSON UI schema (depending on WIDGET_MODE)]
12
+ </WIDGET>
13
+
14
+ The parser splits these apart. Text goes to chat; widget payload goes to renderer.
15
+ This matches Claude's architecture: one model, one generation, no sequential delay.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from typing import Tuple
22
+
23
+ from . import config
24
+ from .widget_prompt import inject_design_system
25
+
26
+ _JSON_WIDGET_RULE = """
27
+ WIDGET JSON SCHEMA MODE (WIDGET_MODE=json):
28
+ - The content inside <WIDGET> MUST be valid JSON (no markdown fences, no comments).
29
+ - Root object: { "version": "1.0", "layout": [ ... ] }
30
+ - layout is an ordered array of blocks (top-to-bottom).
31
+ - Supported block types ONLY (do not invent new ones):
32
+ - text:
33
+ { "type": "text", "id": "...", "content": "..." }
34
+ - kpi_row:
35
+ { "type": "kpi_row", "id": "...", "items": [ { "label": "...", "value": "...", "tone": "positive|neutral|negative" }, ... ] }
36
+ - chart:
37
+ { "type": "chart", "id": "...", "title": "...", "chart": { "kind": "line|bar", "x_label": "...", "y_label": "...", "series": [ { "name": "...", "color": "blue|orange|green|red|purple", "values": [ [x, y], ... ] }, ... ] } }
38
+ - table:
39
+ { "type": "table", "id": "...", "title": "...", "columns": [ ... ], "rows": [ [ ... ], ... ] }
40
+ - action_row:
41
+ { "type": "action_row", "id": "...", "buttons": [ { "id": "...", "label": "...", "intent": "..." }, ... ] }
42
+
43
+ Data grounding:
44
+ - Every entity/name/number used in the schema must come from the user message OR the numbers you include in <RESPONSE>.
45
+ - Do not fabricate time series. If you cannot produce real points, use KPIs + a table.
46
+
47
+ Interactivity:
48
+ - Use action_row buttons to request follow-ups via intent strings (e.g., "explain_methodology", "show_risks").
49
+ """
50
+
51
+
52
+ # ── Design system injected into combined output ────────────────────────────
53
+
54
+ _DESIGN_SYSTEM_REMINDER = """
55
+ A CSS design system is pre-injected into every widget iframe. Use ONLY these variables:
56
+ --bg, --bg2, --bg3 (backgrounds) --text, --text2, --text3 (text)
57
+ --border, --border2 (borders) --accent, --accent-bg, --accent-b (blue)
58
+ --success, --success-bg (green) --warn, --warn-bg (amber)
59
+ --danger, --danger-bg (red) --radius, --radius-sm, --radius-pill
60
+
61
+ Pre-built CSS classes (use them directly, no need to redefine):
62
+ .card .raised .card-title .tabs .tab .panel .search .pills .pill
63
+ .ctrl-row .ctrl-lbl .ctrl-val .btn-group .btn .ask-btn
64
+ .badge .b-blue .b-green .b-amber .b-red .b-gray
65
+ .metric-grid .metric .metric-lbl .metric-val
66
+ .progress-wrap .progress-bar .result-box .result-lbl .result-val .result-sub
67
+ .step-row .step-num .step-title .step-desc .count-lbl .empty
68
+ """
69
+
70
+ _SENDPROMPT_RULE = """
71
+ Always define and use this exact bridge function inside <WIDGET>:
72
+ function sendPrompt(t){window.parent.postMessage({type:"streamlit:setComponentValue",value:t},"*");}
73
+ Every clickable card, row, chip, and button must call sendPrompt with a specific, contextual message.
74
+ """
75
+
76
+ _REACTIVE_RUNTIME_RULE = """
77
+ Universal reactive mini-app contract (follow for every widget):
78
+ - Your widget MUST follow this exact execution model:
79
+ 1) Define:
80
+ - const data = ... // embedded data derived ONLY from user/context and your <RESPONSE>
81
+ - const state = {...} // ALL user inputs (sliders/filters/selections). Initial values must match exact numbers you used in <RESPONSE>.
82
+ 2) Implement:
83
+ - function compute(state, data) { return {...} } // pure transforms: filter/aggregate/calc/sort. No network.
84
+ - function render() { const c = compute(state, data); ... update DOM + chart + table from c ... }
85
+ 3) On load: always call render() once so the widget is never empty.
86
+ 4) On interaction: update state -> call render() immediately (instant UX; never call the LLM on slider drag).
87
+ 5) sendPrompt: ONLY when new knowledge/data is required. Include current state in the prompt.
88
+
89
+ Charts:
90
+ - MUST use ECharts as the primary visualization library.
91
+ - Allowed CDN (preferred): https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js
92
+ - ECharts background must be transparent.
93
+
94
+ Forbidden (never include):
95
+ - fetch / XMLHttpRequest / WebSocket
96
+ - eval / new Function
97
+ """
98
+
99
+ _OUTPUT_CONTRACT_STRICT = """
100
+ OUTPUT CONTRACT (STRICT — MUST FOLLOW)
101
+ You MUST return EXACTLY two sections, in this exact order:
102
+
103
+ <RESPONSE>
104
+ ...text...
105
+ </RESPONSE>
106
+ <WIDGET>
107
+ ...widget...
108
+ </WIDGET>
109
+
110
+ Rules:
111
+ - NEVER omit <WIDGET>.
112
+ - NEVER return only text.
113
+ - If you are uncertain or missing data, STILL return a valid widget that:
114
+ - clearly labels any values as approximate, and
115
+ - includes controls + an ECharts chart driven by embedded (approximate) data, and
116
+ - includes one or more sendPrompt() buttons asking the user for the missing data (e.g., date range / source).
117
+ If you fail to follow this contract, the system will break.
118
+ """
119
+
120
+ _LIBRARIES_RULE = """
121
+ Allowed public libraries (CDN) for <WIDGET>:
122
+ - ECharts (BI-grade charts): https://cdnjs.cloudflare.com/ajax/libs/echarts/5.5.0/echarts.min.js
123
+ - Plotly.js (high-interactivity charts): https://cdn.plot.ly/plotly-2.30.0.min.js
124
+ - D3.js (bespoke/custom visuals): https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js
125
+ - Tabulator JS (tables): https://cdn.jsdelivr.net/npm/tabulator-tables@6.2.5/dist/js/tabulator.min.js
126
+ - Tabulator CSS: https://cdn.jsdelivr.net/npm/tabulator-tables@6.2.5/dist/css/tabulator.min.css
127
+ - Chart.js (only if truly needed): https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js
128
+
129
+ Rules:
130
+ - Do NOT use any other external libraries.
131
+ - Choose exactly ONE chart engine for the main visualization: ECharts OR Plotly OR D3.
132
+ - You may use Tabulator concurrently for detail tables.
133
+ - Never mix multiple chart engines for the same chart area.
134
+
135
+ Color + theming baseline (applies to every engine):
136
+ - Always define a JS palette (array of hex colors) and apply it explicitly to series/marks.
137
+ - Detect dark mode with: const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
138
+ - Explicitly set: axis label color, grid line color, legend text color, and tooltip styling.
139
+
140
+ Library choice guidance (use the best fit; do not force the same layout every time):
141
+ - Time-series trends (date/time x-axis, >=5 points): ECharts line/area + tooltip + subtle dataZoom.
142
+ - Categorical rankings (categories with numeric values): ECharts horizontal bar + click-to-filter + cross-filter table.
143
+ - Composition/share: stacked bars (or 100% stacked) with tooltip value + %; pie/donut only when 3–5 short categories.
144
+ - Distributions:
145
+ - if you have raw samples: histogram-like bins
146
+ - if you only have summary stats: do not invent bins; use KPI tiles + short explanation.
147
+ - Correlation/relationship (x-y pairs): ECharts scatter; highlight outliers.
148
+ - Hierarchies: treemap only when parent/child is explicit; otherwise use grouped table.
149
+ - Many series: avoid clutter; use small multiples or series toggles (do not plot >6 lines by default).
150
+ - Tables: Tabulator always for scan/sort/filter when it helps (rows > 8 or user asked for a breakdown).
151
+ - Prose/conceptual answers with no extractable dataset: return <WIDGET></WIDGET> or a compact visual card (no chart/table shells).
152
+
153
+ Engine-specific rendering requirements:
154
+ - ECharts: option.backgroundColor must be 'transparent'; set textStyle/axis/grid colors from theme.
155
+ - Plotly: set paper_bgcolor/plot_bgcolor to 'rgba(0,0,0,0)'; set layout.font.color and layout.colorway=palette.
156
+ - D3: create SVG with responsive sizing; set tooltip styles; apply palette for strokes/fills.
157
+ """
158
+
159
+ _ANALYTICS_DEFAULTS_RULE = """
160
+ Dashboard decision policy (data-driven; do this internally—do not output the reasoning):
161
+ 1) DATASET EXTRACTION:
162
+ - Extract ONE canonical dataset in JS: `const data = [...]` derived from extractable numeric anchors from this conversation:
163
+ (a) numeric values explicitly present in the user request/context you were given, and
164
+ (b) any numeric values you include inside <RESPONSE>.
165
+ - Every KPI/table/chart label and every numeric value used in the widget must be present somewhere in this conversation context (either in the user request/context or in <RESPONSE>).
166
+ 2) DATA-SHAPE DETECTION:
167
+ - Determine shape: time-series, categorical ranking, composition, distribution, correlation, hierarchy, steps/process, or other.
168
+ 3) WIDGET WARRANT:
169
+ - If no extractable dataset (or too few points): return <WIDGET></WIDGET> or a compact single-card visual (no chart/table).
170
+ 4) BI LAYOUT (only when warranted):
171
+ - KPI row (3–6 tiles) → optional Controls row → Primary visualization → optional detail table → Insights (2–4).
172
+ 5) CROSS-VIEW INTERACTION:
173
+ - Any filter/control must update KPIs + chart + table from the SAME filtered dataset.
174
+ 6) DRILLDOWN LOOP:
175
+ - Click chart mark / legend / table row → sendPrompt('...') with clicked entity + metric + relevant time window (if present) + current filter summary.
176
+ 7) INSIGHT RULE:
177
+ - Insights must be computed from the dataset in JS (or computed from extracted values). Do not write obvious generic commentary.
178
+ """
179
+
180
+ _COLOR_THEMING_RULE = """
181
+ Color & theming (best-in-class readability + polish):
182
+ - You may choose ANY colors, but they MUST remain readable and “enterprise clean”.
183
+ - Detect dark mode with: const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
184
+ - Create theme tokens in JS:
185
+ - text = dark ? '#e8eaf4' : '#111318'
186
+ - text2 = dark ? '#8d93aa' : '#5a5f72'
187
+ - grid = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'
188
+ - border = dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)'
189
+ - Define palette in JS (hex array) and use it explicitly.
190
+ - Deterministic category coloring:
191
+ - Build `colorMap` from category keys to palette entries (stable ordering).
192
+ - Reuse the same `colorMap` for KPIs and chart series/marks.
193
+ - Selection/interaction states:
194
+ - Hover: subtle opacity/brightness change
195
+ - Selected: stronger accent (thicker stroke/line), not neon
196
+ - Grid/labels must always be visible: explicitly set label/text/grid colors for the chart engine.
197
+ """
198
+
199
+
200
+ def build_combined_system_prompt(
201
+ strategy_id: str,
202
+ format_rule: str,
203
+ primitive_extra_context: str,
204
+ user_message: str,
205
+ forbidden_components: list[str] | None = None,
206
+ required_components: list[str] | None = None,
207
+ ) -> str:
208
+ """
209
+ Build the combined system prompt for a single LLM call that outputs
210
+ both the response text and the widget HTML together.
211
+
212
+ Args:
213
+ strategy_id: selected strategy name (e.g. 'comparison_table')
214
+ format_rule: primitive format instruction for response text
215
+ primitive_extra_context: widget layout instructions from primitives.json
216
+ forbidden_components: component names the widget MUST NOT use
217
+ required_components: component names the widget MUST use
218
+ """
219
+ widget_block = ""
220
+ if primitive_extra_context:
221
+ widget_block = f"""
222
+ ## Widget layout instructions (follow these exactly)
223
+ {primitive_extra_context}
224
+ """
225
+
226
+ constraint_block = ""
227
+ if forbidden_components:
228
+ names = ", ".join(forbidden_components)
229
+ constraint_block += (
230
+ "\n## FORBIDDEN — do NOT use these components inside <WIDGET>\n"
231
+ f"{names}\n"
232
+ "If your HTML contains any of these, the widget will be rejected and replaced.\n"
233
+ )
234
+ if required_components:
235
+ names = ", ".join(required_components)
236
+ constraint_block += (
237
+ "\n## REQUIRED — your <WIDGET> MUST contain these components\n"
238
+ f"{names}\n"
239
+ "If your HTML is missing any of these, the widget will be rejected and replaced.\n"
240
+ )
241
+
242
+ response_rule_line = (
243
+ "Follow this exactly for the text inside <RESPONSE>."
244
+ if getattr(config, "STRICT_PRIMITIVES", False)
245
+ else "Treat this as a style hint for <RESPONSE> (do not be rigid)."
246
+ )
247
+
248
+ um = (user_message or "").lower()
249
+ explicit_visual = any(
250
+ k in um
251
+ for k in (
252
+ "plot",
253
+ "chart",
254
+ "graph",
255
+ "visualize",
256
+ "visualisation",
257
+ "visualization",
258
+ "dashboard",
259
+ "scatter",
260
+ "line chart",
261
+ "bar chart",
262
+ "histogram",
263
+ "heatmap",
264
+ "candlestick",
265
+ )
266
+ )
267
+ visual_override_block = ""
268
+ if explicit_visual:
269
+ visual_override_block = """
270
+ ═══════════════════════════════════════════════════════
271
+ EXPLICIT VISUAL OVERRIDE (from user message)
272
+ ═══════════════════════════════════════════════════════
273
+ The user explicitly asked for a visualization. Prioritize a chart/graph in <WIDGET>.
274
+ If the Strategy/Rule suggests a table-only response, you may still answer in a chart-friendly structure and should include an interactive chart in <WIDGET>.
275
+ Do not refuse a chart just because a "comparison_table" style was selected.
276
+ """
277
+
278
+ widget_mode = getattr(config, "WIDGET_MODE", "json").strip().lower()
279
+ widget_format_line = (
280
+ "Complete self-contained HTML document for the interactive widget"
281
+ if widget_mode != "json"
282
+ else "JSON UI schema ONLY (no HTML) for the widget"
283
+ )
284
+
285
+ widget_rules_header = (
286
+ "WIDGET RULES — for the HTML inside <WIDGET>"
287
+ if widget_mode != "json"
288
+ else "WIDGET RULES — for the JSON schema inside <WIDGET>"
289
+ )
290
+
291
+ widget_rules_body = (
292
+ f"""- Hard output contract (never violate):
293
+ - You MUST output BOTH tags exactly once: <RESPONSE>...</RESPONSE> and <WIDGET>...</WIDGET>.
294
+ - Never omit <WIDGET> tags. If you choose “no widget”, output literally `<WIDGET></WIDGET>` (empty but present).
295
+ - Widget CONTENT is OPTIONAL (based on data), but the <WIDGET> tags are REQUIRED.
296
+ - Choose the UI based on the content in <RESPONSE> (data-driven). Do not follow any fixed template.
297
+ - IMPORTANT: In HTML mode, the content inside <WIDGET> MUST be HTML (not JSON). It must contain opening <html> and closing </html>.
298
+ - Return a COMPLETE, self-contained HTML document (opening <html> to closing </html>).
299
+ - Inline ALL CSS in <style> and ALL JS in <script>. The only external files allowed are the CDNs below (and Tabulator CSS via <link>).
300
+ - {_LIBRARIES_RULE.strip()}
301
+ - {_ANALYTICS_DEFAULTS_RULE.strip()}
302
+ - {_COLOR_THEMING_RULE.strip()}
303
+ - No frameworks (React/Vue/jQuery). Plain HTML + CSS + JS only.
304
+ - body background must be transparent (background:transparent!important).
305
+ - No position:fixed anywhere.
306
+ - Wrap content in <div class="widget-root">.
307
+ - No markdown fences/backticks inside <WIDGET>. Use ONLY raw HTML/CSS/JS.
308
+ - Always call your main render/calc function once on page load so output is never empty (e.g., call `init()` or `render()` at the end of <script>).
309
+ - Charts/tables must be drawn from the embedded dataset immediately after the first render call.
310
+ - Slider/input changes → local calc() only (never sendPrompt on drag).
311
+ - Slider initial values MUST match the exact numbers in your <RESPONSE>. Never invent defaults.
312
+ - Use 0.5px solid borders — never 1px solid.
313
+ - UI (HTML/CSS): use CSS variables only (no hardcoded hex/rgb). Charts (ECharts/Plotly/Chart.js): you MAY use hex colors in JS configs for palettes/series.
314
+ {_REACTIVE_RUNTIME_RULE}
315
+ {_DESIGN_SYSTEM_REMINDER}
316
+ {_SENDPROMPT_RULE}"""
317
+ if widget_mode != "json"
318
+ else _JSON_WIDGET_RULE.strip()
319
+ )
320
+
321
+ return f"""You are an expert AI assistant with rich interactive output capabilities.
322
+
323
+ {_OUTPUT_CONTRACT_STRICT}
324
+
325
+ For every response you produce TWO sections — response text and an interactive widget — in one generation.
326
+ Default behavior: generate a NON-EMPTY <WIDGET> that turns your own <RESPONSE> into something interactive/visual.
327
+ Only return an EMPTY widget block (<WIDGET></WIDGET>) when the turn is truly not “widget-worthy”:
328
+ - greetings / acknowledgements / chit-chat
329
+ - or ultra-short answers with no structure and no numbers (≈ 1–2 sentences, no steps, no table, no formula)
330
+
331
+ Important: users should NOT have to ask for “a widget” every time. If your <RESPONSE> contains structure or numbers,
332
+ you are expected to automatically provide the best-fit widget.
333
+
334
+ Exception (must generate non-empty widget):
335
+ - If the user explicitly requests any of the following: visualization/dashboard/chart/graph/plot, **interactive** output, **widget**, **calculator**, **sliders**, or **interactive table**,
336
+ OR provides explicit numeric arrays/series/time points in the conversation context,
337
+ you MUST generate a NON-EMPTY <WIDGET> (not just empty tags).
338
+ - For calculators/sliders: a compact interactive calculator widget is acceptable even without a “dataset”.
339
+ - For plots/charts: include at least one interactive chart (ECharts/Plotly/D3) and optionally a sortable table (Tabulator).
340
+
341
+ Auto-widget triggers (based on what YOU wrote in <RESPONSE>):
342
+ - If <RESPONSE> includes any numeric values, percentages, currency, or formulas → generate an interactive calculator and/or visualization.
343
+ - If <RESPONSE> includes a list of steps or a comparison → generate an interactive checklist, decision table, or sortable table (Tabulator).
344
+ - If <RESPONSE> includes 5+ numeric points or a time series → generate an interactive chart (ECharts/Plotly/D3).
345
+ - If <RESPONSE> includes only 1–4 numeric points → generate KPI tiles + a small chart if it adds value (no fake data).
346
+ {visual_override_block}
347
+
348
+ ═══════════════════════════════════════════════════════
349
+ OUTPUT FORMAT — always use exactly this structure
350
+ ═══════════════════════════════════════════════════════
351
+ <RESPONSE>
352
+ [Your answer here — follow the FORMAT RULE below]
353
+ </RESPONSE>
354
+ <WIDGET>
355
+ [{widget_format_line}]
356
+ </WIDGET>
357
+
358
+ ═══════════════════════════════════════════════════════
359
+ RESPONSE FORMAT RULE — {response_rule_line}
360
+ ═══════════════════════════════════════════════════════
361
+ Strategy: {strategy_id}
362
+ Rule: {format_rule}
363
+ Do not mention this rule. Do not add <WIDGET> inside <RESPONSE>.
364
+ Important: The chosen Rule applies ONLY inside <RESPONSE>. The <WIDGET> section must follow the WIDGET RULES (not the RESPONSE rule).
365
+
366
+ ═══════════════════════════════════════════════════════
367
+ {widget_rules_header}
368
+ ═══════════════════════════════════════════════════════
369
+ {widget_rules_body}
370
+ {widget_block}
371
+ {constraint_block}
372
+ ═══════════════════════════════════════════════════════
373
+ DATA GROUNDING — most critical quality rule
374
+ ═══════════════════════════════════════════════════════
375
+ Every entity, name, number, ticker, percentage shown in the widget MUST come from either:
376
+ - the numeric values you extracted from the user request/context, OR
377
+ - the numeric values present in your <RESPONSE>.
378
+ - Never invent data. Never use placeholder names (Item A, Value 1, Example Fund).
379
+ - If data is missing for a control, omit that control.
380
+ - Verify every chart/table label and every numeric literal used in JS appears in either your <RESPONSE> or the user-provided/context values.
381
+ - Do not create placeholder dates, tickers, or values to make the widget “look full”.
382
+
383
+ ═══════════════════════════════════════════════════════
384
+ sendPrompt specificity — always specific, never generic
385
+ ═══════════════════════════════════════════════════════
386
+ GOOD: sendPrompt('What are the risks of VTI at 0.03% expense ratio?')
387
+ BAD: sendPrompt('Tell me more')
388
+ BAD: sendPrompt('Click for details')
389
+ """
390
+
391
+
392
+ def build_combined_user_prompt(
393
+ user_message: str,
394
+ history: list[dict],
395
+ max_history: int = 4,
396
+ ) -> str:
397
+ """Build the user-turn prompt including conversation history."""
398
+ ctx: list[str] = []
399
+ for turn in history[-max_history:]:
400
+ u = turn.get("user", "")
401
+ a = turn.get("assistant", "")
402
+ if u:
403
+ ctx.append(f"User: {u}")
404
+ if a:
405
+ # Strip any <WIDGET>...</WIDGET> from stored history to keep it concise.
406
+ a_clean = re.sub(r"<WIDGET>.*?</WIDGET>", "", a, flags=re.DOTALL).strip()
407
+ ctx.append(f"Assistant: {a_clean}")
408
+ ctx.append(f"User: {user_message}")
409
+ return "\n".join(ctx)
410
+
411
+
412
+ def parse_combined_output(raw: str) -> Tuple[str, str]:
413
+ """
414
+ Parse model combined output into (response_text, widget_payload).
415
+
416
+ Returns:
417
+ (response_text, widget_payload)
418
+ Either can be empty string if the tag is missing or parsing fails.
419
+ """
420
+ response_text = ""
421
+ widget_payload = ""
422
+
423
+ # Extract <RESPONSE>...</RESPONSE>
424
+ resp_match = re.search(r"<RESPONSE>(.*?)</RESPONSE>", raw, re.DOTALL | re.IGNORECASE)
425
+ if resp_match:
426
+ response_text = resp_match.group(1).strip()
427
+ else:
428
+ # Fallback: everything before <WIDGET> is the response
429
+ widget_start = raw.find("<WIDGET>")
430
+ if widget_start == -1:
431
+ widget_start_upper = raw.upper().find("<WIDGET>")
432
+ if widget_start_upper != -1:
433
+ widget_start = widget_start_upper
434
+ if widget_start > 0:
435
+ response_text = raw[:widget_start].strip()
436
+ else:
437
+ response_text = raw.strip()
438
+
439
+ # Extract <WIDGET>...</WIDGET>
440
+ widget_match = re.search(r"<WIDGET>(.*?)</WIDGET>", raw, re.DOTALL | re.IGNORECASE)
441
+ if widget_match:
442
+ raw_widget = widget_match.group(1).strip()
443
+ # Strip markdown fences if model wrapped in ```...```
444
+ if "```" in raw_widget:
445
+ fence = re.search(r"```(?:json|html)?\s*(.*?)```", raw_widget, re.DOTALL | re.IGNORECASE)
446
+ raw_widget = fence.group(1).strip() if fence else re.sub(r"```\w*", "", raw_widget).strip()
447
+
448
+ widget_mode = getattr(config, "WIDGET_MODE", "json").strip().lower()
449
+ if widget_mode == "json":
450
+ widget_payload = raw_widget
451
+ else:
452
+ if "<" in raw_widget and ">" in raw_widget:
453
+ if "<html" not in raw_widget.lower():
454
+ raw_widget = f"<html><head></head><body>{raw_widget}</body></html>"
455
+ raw_widget = re.sub(r"<!DOCTYPE[^>]*>", "", raw_widget, flags=re.IGNORECASE).strip()
456
+ widget_payload = inject_design_system(raw_widget)
457
+
458
+ return response_text, widget_payload
anupa/backend/config.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration and constants for the Adaptive Presentation Engine backend."""
2
+
3
+ from pathlib import Path
4
+ from dotenv import load_dotenv
5
+ import os
6
+
7
+ load_dotenv(override=True)
8
+ # Runtime mode: 'openai_compat' uses an OpenAI-compatible remote API,
9
+ # otherwise the code will prefer Anthropic (Claude) endpoints.
10
+ LLM_MODE = os.getenv("LLM_MODE", "openai_compat").lower()
11
+
12
+ # If false (default), strategy “primitives” are treated as soft style hints
13
+ # and the backend will not aggressively post-process text to enforce them.
14
+ STRICT_PRIMITIVES = os.getenv("STRICT_PRIMITIVES", "0").strip().lower() in {"1", "true", "yes", "on"}
15
+
16
+ # Provider routing:
17
+ # - /api/chat_plain uses BASELINE_LLM_MODE
18
+ # - /api/chat uses ADAPTIVE_LLM_MODE
19
+ BASELINE_LLM_MODE = os.getenv("BASELINE_LLM_MODE", LLM_MODE).lower()
20
+ ADAPTIVE_LLM_MODE = os.getenv("ADAPTIVE_LLM_MODE", LLM_MODE).lower()
21
+
22
+ # Widget rendering mode:
23
+ # - "html": model outputs full HTML in <WIDGET> (rendered in iframe)
24
+ # - "json": model outputs JSON UI schema in <WIDGET> (rendered by frontend renderer)
25
+ WIDGET_MODE = os.getenv("WIDGET_MODE", "json").strip().lower()
26
+
27
+ # Combined (Claude-style) generation limits
28
+ COMBINED_TIMEOUT_SECONDS = int(os.getenv("COMBINED_TIMEOUT_SECONDS", "30"))
29
+ COMBINED_MAX_TOKENS = int(os.getenv("COMBINED_MAX_TOKENS", "2800"))
30
+
31
+ # OpenAI-compatible endpoint (Groq / other providers that offer OpenAI API)
32
+ OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.groq.com/openai/v1")
33
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
34
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "llama-3.1-8b-instant")
35
+
36
+ # Anthropic (Claude) API
37
+ ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
38
+ ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-opus-4-6")
39
+
40
+ # Anthropic Fast Mode (optional; safe to enable because we retry on failure)
41
+ # Implementation uses the Anthropic beta header + request body speed field via SDK-supported
42
+ # `extra_headers` / `extra_body`.
43
+ ANTHROPIC_FAST_MODE_ENABLED = os.getenv("ANTHROPIC_FAST_MODE_ENABLED", "1").strip().lower() in {
44
+ "1", "true", "yes", "on"
45
+ }
46
+ ANTHROPIC_FAST_MODE_SPEED = os.getenv("ANTHROPIC_FAST_MODE_SPEED", "fast") # "fast" or "standard"
47
+ ANTHROPIC_FAST_MODE_BETA = os.getenv("ANTHROPIC_FAST_MODE_BETA", "fast-mode-2026-02-01")
48
+
49
+ # Model / engine hyperparameters
50
+ D = 10
51
+ LAMBDA = 0.01
52
+ GAMMA = 0.99
53
+ ALPHA_G = 0.05
54
+
55
+ # Base Thompson temperature (used when not forcing exploration)
56
+ TS_TEMPERATURE = float(os.getenv("TS_TEMPERATURE", "2.0"))
57
+
58
+ # ---- Exploration / correction knobs (Option B) ----
59
+ # When we detect strong negative feedback, we:
60
+ # - boost temperature (more exploration)
61
+ # - penalize repeating the last strategy (strongly)
62
+ # - damp the posterior for the last strategy (reduce confidence)
63
+ NEG_EXPLORE_THRESHOLD = float(os.getenv("NEG_EXPLORE_THRESHOLD", "0.40")) # ev["neg"] >= this => explore
64
+ EXPLORE_TEMP_BOOST = float(os.getenv("EXPLORE_TEMP_BOOST", "2.0"))
65
+ EXPLORE_SCORE_PENALTY = float(os.getenv("EXPLORE_SCORE_PENALTY", "1.75"))
66
+
67
+ # Posterior damping strength (scaled by neg_strength in [0,1])
68
+ NEG_MU_SHRINK = float(os.getenv("NEG_MU_SHRINK", "0.25")) # shrink mean magnitude
69
+ NEG_SINV_SHRINK = float(os.getenv("NEG_SINV_SHRINK", "0.35")) # shrink precision -> bigger covariance
70
+
71
+ # Strategy primitives and descriptions used to constrain generation style
72
+ STRATEGIES = {
73
+ "structured_bullets": "Use 3-5 bullet points only (start each line with '- '). No intro sentence. Do NOT ask questions. Do NOT use numbered lists.",
74
+ "narrative_prose": "Write 2-3 short paragraphs. No bullet points.",
75
+ "concise_direct": "Reply in at most 3 sentences. Be direct.",
76
+ "socratic_questions": "Brief acknowledgement, then ask 1-2 clarifying questions.",
77
+ "step_by_step": "Numbered list of 3-6 steps only.",
78
+ # NEW
79
+ "comparison_table": "Return a single MARKDOWN TABLE only. Use columns that help compare options (e.g., Option | Pros | Cons | Best for). No bullets outside the table.",
80
+ "visualization": "Return a simple TEXT visualization only (ASCII bar chart or small table-of-values). Put it in a fenced code block. No extra prose outside the code block.",
81
+ }
82
+ STRATEGY_NAMES = list(STRATEGIES.keys())
83
+ K = len(STRATEGY_NAMES)
84
+
85
+ HERE = Path(__file__).resolve().parent
86
+ INDEX_HTML = HERE.parent / "index.html"
anupa/backend/engine.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bayesian engine for selecting and updating response strategies.
2
+
3
+ Implements a lightweight contextual bandit (Thompson sampling style) over
4
+ presentation strategies. Supports:
5
+ - soft preferences (bias, not lock)
6
+ - optional hard lock (user can disable exploration)
7
+ - Option B corrective exploration after negative feedback:
8
+ * temperature boost
9
+ * strong repeat penalty
10
+ * posterior damping (reduce confidence in last chosen arm)
11
+ """
12
+
13
+ import numpy as np
14
+ import random
15
+
16
+ from . import config
17
+ from .utils import sigmoid, mean_uncertainty
18
+
19
+
20
+ class BayesianEngine:
21
+ def __init__(self):
22
+ self.global_mu = {k: np.zeros(config.D) for k in config.STRATEGY_NAMES}
23
+ self.global_sinv = {k: np.eye(config.D) * 0.1 for k in config.STRATEGY_NAMES}
24
+ self.users = {}
25
+ self.global_n = 0
26
+
27
+ def _new_user(self):
28
+ return {
29
+ "mu": {k: self.global_mu[k].copy() for k in config.STRATEGY_NAMES},
30
+ "sigma_inv": {k: self.global_sinv[k].copy() for k in config.STRATEGY_NAMES},
31
+ "history": [],
32
+ "reward_log": [],
33
+ "last_message": "",
34
+ "last_response": "",
35
+ "last_strategy": None,
36
+ "last_x": None,
37
+ "msg_count": 0,
38
+ "prefs": set(),
39
+ "locked_strategy": None,
40
+ "pending_strategy": None,
41
+ }
42
+
43
+ def get_user(self, uid: str):
44
+ if uid not in self.users:
45
+ self.users[uid] = self._new_user()
46
+ return self.users[uid]
47
+
48
+ def featurize(self, message: str, user: dict) -> np.ndarray:
49
+ words = message.split()
50
+ msg_len = min(len(message) / 500, 1.0)
51
+ word_ct = min(len(words) / 100, 1.0)
52
+ has_q = 1.0 if "?" in message else 0.0
53
+ is_long = 1.0 if len(words) > 40 else 0.0
54
+ informal = {"lol","gonna","wanna","yo","omg","idk","wtf","lmao"}
55
+ formal = 0.0 if any(w in message.lower() for w in informal) else 1.0
56
+ rl = user["reward_log"]
57
+ avg_r = sum(r for _, r in rl[-5:]) / max(len(rl[-5:]), 1) if rl else 0.5
58
+ msg_num = min(user["msg_count"] / 20.0, 1.0)
59
+ si = config.STRATEGY_NAMES.index(user["last_strategy"]) / (config.K-1) if user["last_strategy"] else 0.5
60
+ trend = 0.0
61
+ if len(rl) >= 3:
62
+ ys = [r for _, r in rl[-5:]]
63
+ xs = list(range(len(ys)))
64
+ mx, my = sum(xs)/len(xs), sum(ys)/len(ys)
65
+ num = sum((xi-mx)*(yi-my) for xi, yi in zip(xs, ys))
66
+ den = sum((xi-mx)**2 for xi in xs) or 1e-8
67
+ trend = float(np.clip(num/den, -1, 1))
68
+ # last dim is just a small noise to break ties
69
+ return np.array([msg_len, word_ct, has_q, is_long,
70
+ formal, avg_r, msg_num, si, trend, random.random()])
71
+
72
+ def _damp_posterior(self, user: dict, strategy: str, strength: float):
73
+ """Option B: reduce confidence in the last chosen strategy."""
74
+ if strategy not in config.STRATEGY_NAMES:
75
+ return
76
+ s = float(np.clip(strength, 0.0, 1.0))
77
+ if s <= 0:
78
+ return
79
+ # shrink mean magnitude
80
+ mu = user["mu"][strategy]
81
+ user["mu"][strategy] = mu * (1.0 - config.NEG_MU_SHRINK * s)
82
+
83
+ # shrink precision -> increases covariance (more uncertainty)
84
+ fac = max(0.15, 1.0 - config.NEG_SINV_SHRINK * s)
85
+ user["sigma_inv"][strategy] = user["sigma_inv"][strategy] * fac
86
+
87
+ def select(self, uid: str, message: str, *,
88
+ force_explore: bool = False,
89
+ neg_strength: float = 0.0,
90
+ explicit_strategy: str | None = None):
91
+ """Return (chosen, scores, x, prev_strategy)."""
92
+ user = self.get_user(uid)
93
+ prev = user.get("last_strategy")
94
+ x = self.featurize(message, user)
95
+
96
+ # One-time override: honor the upfront user-selected format once,
97
+ # then return to adaptive Thompson Sampling on later turns.
98
+ pending = user.get("pending_strategy")
99
+ if pending in config.STRATEGY_NAMES:
100
+ user["pending_strategy"] = None
101
+ return pending, {k: 0.0 for k in config.STRATEGY_NAMES}, x, prev
102
+
103
+ # If user explicitly asked for a format, obey immediately.
104
+ # Hard-lock disables exploration, but should NOT block an explicit request like
105
+ # "compare X vs Y" or "put it in a table".
106
+ locked = user.get("locked_strategy")
107
+ if locked in config.STRATEGY_NAMES:
108
+ force_explore = False
109
+ if explicit_strategy is None:
110
+ explicit_strategy = locked
111
+
112
+ # Corrective exploration: damp posterior on prev to avoid getting stuck.
113
+ if force_explore and prev:
114
+ self._damp_posterior(user, prev, neg_strength)
115
+
116
+ # Build TS scores
117
+ temp = config.TS_TEMPERATURE * (config.EXPLORE_TEMP_BOOST if force_explore else 1.0)
118
+ scores = {}
119
+ for k in config.STRATEGY_NAMES:
120
+ sigma = np.linalg.inv(user["sigma_inv"][k])
121
+ beta = np.random.multivariate_normal(user["mu"][k], sigma * temp)
122
+ scores[k] = float(sigmoid(x @ beta))
123
+
124
+ # Soft preference nudges (does NOT lock)
125
+ pref_boost = 0.06
126
+ for s in user.get("prefs", set()):
127
+ if s in scores:
128
+ scores[s] = float(min(0.999, scores[s] + pref_boost))
129
+
130
+ # Strong anti-repeat penalty when exploring (unless explicit override)
131
+ if force_explore and prev and prev in scores and explicit_strategy is None and len(config.STRATEGY_NAMES) > 1:
132
+ scores[prev] = float(scores[prev] - config.EXPLORE_SCORE_PENALTY)
133
+
134
+ # Choose
135
+ # Explicit user request (e.g., "compare", "table", "graph") should win even if the user
136
+ # previously hard-locked a default style. The lock only disables exploration.
137
+ if explicit_strategy in config.STRATEGY_NAMES:
138
+ chosen = explicit_strategy
139
+ elif locked in config.STRATEGY_NAMES:
140
+ chosen = locked
141
+ else:
142
+ chosen = max(scores, key=scores.get)
143
+
144
+ return chosen, scores, x, prev
145
+
146
+ def update(self, uid: str, strategy: str, x: np.ndarray, reward: float):
147
+ user = self.get_user(uid)
148
+ mu_old = user["mu"][strategy]
149
+ si_old = user["sigma_inv"][strategy]
150
+ r_hat = sigmoid(float(x @ mu_old))
151
+ w = r_hat * (1 - r_hat)
152
+
153
+ si_new = config.GAMMA * si_old + np.outer(x, x) * w + config.LAMBDA * np.eye(config.D)
154
+ s_new = np.linalg.inv(si_new)
155
+ user["mu"][strategy] = mu_old + s_new @ x * (reward - r_hat)
156
+ user["sigma_inv"][strategy] = si_new
157
+ user["reward_log"].append((strategy, reward))
158
+
159
+ # Global update
160
+ gmu = self.global_mu[strategy]
161
+ gsi = self.global_sinv[strategy]
162
+ gr_hat = sigmoid(float(x @ gmu))
163
+ gsi_n = config.GAMMA*gsi + np.outer(x,x)*gr_hat*(1-gr_hat)*config.ALPHA_G + config.LAMBDA*np.eye(config.D)
164
+ gs_n = np.linalg.inv(gsi_n)
165
+ self.global_mu[strategy] = gmu + gs_n @ x * (reward - gr_hat) * config.ALPHA_G
166
+ self.global_sinv[strategy] = gsi_n
167
+ self.global_n += 1
168
+
169
+ def apply_preferences(self, uid: str, strategy_names, *, lock: bool = False):
170
+ """Apply soft preferences and optional hard lock.
171
+
172
+ Behavior:
173
+ - if lock=True and exactly one strategy is chosen: always use that strategy
174
+ - if lock=False and exactly one strategy is chosen: force it ONCE on the next turn,
175
+ then return to adaptive TS with preference bias
176
+ """
177
+ user = self.get_user(uid)
178
+ chosen = [s for s in (strategy_names or []) if s in config.STRATEGY_NAMES]
179
+ user["prefs"] = set(chosen)
180
+
181
+ # hard lock only if explicitly requested
182
+ user["locked_strategy"] = chosen[0] if (lock and len(chosen) == 1) else None
183
+
184
+ # one-turn override for the very next assistant response
185
+ user["pending_strategy"] = chosen[0] if (not lock and len(chosen) == 1) else None
186
+
187
+ # warm-start: nudge mean for preferred arms
188
+ for s in user["prefs"]:
189
+ user["mu"][s] = user["mu"][s] + 0.5
190
+
191
+ def posterior_summary(self, mu_dict, sinv_dict, x=None):
192
+ if x is None:
193
+ x = np.ones(config.D) * 0.5
194
+ return {k: {"r": round(float(sigmoid(x @ mu_dict[k])), 4),
195
+ "u": round(mean_uncertainty(sinv_dict[k]), 4)}
196
+ for k in config.STRATEGY_NAMES}
197
+
198
+ def user_posterior(self, uid: str, x=None):
199
+ u = self.get_user(uid)
200
+ return self.posterior_summary(u["mu"], u["sigma_inv"], x)
201
+
202
+ def global_posterior(self, x=None):
203
+ return self.posterior_summary(self.global_mu, self.global_sinv, x)
204
+
205
+ def reset_user(self, uid: str):
206
+ self.users.pop(uid, None)
207
+
208
+
209
+ engine = BayesianEngine()
210
+ USERB_ID = "__user_b__"
211
+ engine.get_user(USERB_ID)
anupa/backend/llm.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM helpers for Anthropic and OpenAI-compatible endpoints.
2
+
3
+ This module centralizes HTTP calls and provides small helpers used by the
4
+ server to call either Anthropic (Claude) or an OpenAI-compatible API.
5
+ """
6
+
7
+ import json
8
+ import urllib.request
9
+ import urllib.error
10
+ import socket
11
+ import time
12
+ import concurrent.futures
13
+ from typing import Tuple
14
+
15
+ from . import config
16
+
17
+ try:
18
+ import anthropic # type: ignore
19
+ except Exception: # pragma: no cover
20
+ anthropic = None
21
+ _anthropic_import_error = "import_failed"
22
+ else:
23
+ _anthropic_import_error = ""
24
+
25
+
26
+ def _post_json_url(url: str, payload: dict, headers: dict | None = None, timeout: int = 120) -> dict:
27
+ """POST JSON and return decoded response.
28
+
29
+ Raises the underlying urllib errors to the caller for handling.
30
+ """
31
+ data = json.dumps(payload).encode("utf-8")
32
+ req = urllib.request.Request(url, data=data, method="POST")
33
+ req.add_header("Content-Type", "application/json")
34
+ req.add_header("User-Agent", "Mozilla/5.0")
35
+ if headers:
36
+ for k, v in headers.items():
37
+ req.add_header(k, v)
38
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
39
+ return json.loads(resp.read().decode("utf-8"))
40
+
41
+
42
+ def _get_json_url(url: str, headers: dict | None = None, timeout: int = 15) -> dict:
43
+ """GET from a URL and return the decoded JSON response.
44
+
45
+ Args:
46
+ url: Target URL.
47
+ headers: Optional dict of additional HTTP headers.
48
+ timeout: Request timeout in seconds.
49
+
50
+ Returns:
51
+ Decoded JSON response as a dict.
52
+ """
53
+ req = urllib.request.Request(url, method="GET")
54
+ req.add_header("User-Agent", "Mozilla/5.0")
55
+ if headers:
56
+ for k, v in headers.items():
57
+ req.add_header(k, v)
58
+ with urllib.request.urlopen(req, timeout=timeout) as r:
59
+ return json.loads(r.read().decode("utf-8"))
60
+
61
+
62
+ def call_openai_compat(
63
+ prompt: str,
64
+ system: str,
65
+ timeout: int = 120,
66
+ max_tokens: int = 400,
67
+ temperature: float = 0.2,
68
+ ) -> Tuple[str, float, str]:
69
+ """Call an OpenAI-compatible chat/completions endpoint.
70
+
71
+ Returns (text, elapsed_seconds, mode).
72
+ """
73
+ if not config.OPENAI_API_KEY:
74
+ raise RuntimeError("Missing OPENAI_API_KEY env var")
75
+ t0 = time.time()
76
+ payload = {
77
+ "model": config.OPENAI_MODEL,
78
+ "messages": [
79
+ {"role": "system", "content": system},
80
+ {"role": "user", "content": prompt},
81
+ ],
82
+ "temperature": temperature,
83
+ "max_tokens": max_tokens,
84
+ }
85
+ headers = {"Authorization": f"Bearer {config.OPENAI_API_KEY}"}
86
+ data = _post_json_url(f"{config.OPENAI_BASE_URL}/chat/completions", payload, headers=headers, timeout=timeout)
87
+ text = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip()
88
+ return (text or str(data)), round(time.time() - t0, 1), "openai_compat"
89
+
90
+
91
+ def call_anthropic(
92
+ prompt: str,
93
+ system: str,
94
+ timeout: int = 120,
95
+ max_tokens: int = 400,
96
+ temperature: float = 0.2,
97
+ ) -> Tuple[str, float, str]:
98
+ """Call Anthropic Messages API (Claude) and return (text, elapsed_seconds, mode)."""
99
+ if not config.ANTHROPIC_API_KEY:
100
+ raise RuntimeError("Missing ANTHROPIC_API_KEY env var")
101
+ if anthropic is None:
102
+ raise RuntimeError(f"Missing `anthropic` package. Import error: {_anthropic_import_error}")
103
+
104
+ fast_kwargs = {}
105
+ if getattr(config, "ANTHROPIC_FAST_MODE_ENABLED", False):
106
+ fast_kwargs = {
107
+ # Fast Mode is controlled by the `anthropic-beta` header.
108
+ "extra_headers": {"anthropic-beta": getattr(config, "ANTHROPIC_FAST_MODE_BETA", "fast-mode-2026-02-01")},
109
+ # SDK may not expose `speed` directly in our version, but it can be set via extra_body.
110
+ "extra_body": {"speed": getattr(config, "ANTHROPIC_FAST_MODE_SPEED", "fast")},
111
+ }
112
+
113
+ t0 = time.time()
114
+ client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
115
+
116
+ # Anthropic SDK handles timeouts internally; we keep our signature for parity.
117
+ # Some SDK/provider combinations may ignore the SDK-level timeout and hang
118
+ # for a long time. Enforce a wall-clock timeout so the server can respond
119
+ # with an error instead of freezing the UI.
120
+ def _create(with_fast: dict) -> object:
121
+ return client.messages.create(
122
+ model=config.ANTHROPIC_MODEL,
123
+ max_tokens=max_tokens,
124
+ temperature=temperature,
125
+ system=system,
126
+ messages=[{"role": "user", "content": prompt}],
127
+ timeout=timeout,
128
+ **with_fast,
129
+ )
130
+
131
+ msg = None
132
+ try:
133
+ ex = concurrent.futures.ThreadPoolExecutor(max_workers=1)
134
+ try:
135
+ fut = ex.submit(_create, fast_kwargs)
136
+ msg = fut.result(timeout=timeout)
137
+ finally:
138
+ # Do not block waiting for a stuck SDK call to finish.
139
+ ex.shutdown(wait=False, cancel_futures=True)
140
+ except concurrent.futures.TimeoutError as e:
141
+ raise RuntimeError(f"Anthropic request timed out after {timeout}s") from e
142
+ except Exception:
143
+ # If Fast Mode beta isn't accepted for this request/model/SDK version, retry normally.
144
+ ex = concurrent.futures.ThreadPoolExecutor(max_workers=1)
145
+ try:
146
+ fut = ex.submit(_create, {})
147
+ msg = fut.result(timeout=timeout)
148
+ finally:
149
+ ex.shutdown(wait=False, cancel_futures=True)
150
+
151
+ # `content` is typically a list of blocks; concatenate everything we can.
152
+ # This avoids missing parts of the model output if some blocks don't expose
153
+ # `.text` (e.g., non-text block representations).
154
+ texts: list[str] = []
155
+ for block in getattr(msg, "content", []) or []:
156
+ t = getattr(block, "text", None)
157
+ if t is not None:
158
+ texts.append(str(t))
159
+ else:
160
+ texts.append(str(block))
161
+ text = "\n".join([t for t in texts if t]).strip()
162
+ if not text:
163
+ # Fallback: stringify response object.
164
+ text = str(msg).strip()
165
+
166
+ return text, round(time.time() - t0, 1), "anthropic"
167
+
168
+
169
+ def stream_anthropic(
170
+ prompt: str,
171
+ system: str,
172
+ timeout: int = 120,
173
+ max_tokens: int = 400,
174
+ temperature: float = 0.2,
175
+ ):
176
+ """Yield Anthropic content deltas (token-by-token).
177
+
178
+ Yields:
179
+ str chunks of text from `content_block_delta`.
180
+ """
181
+ if not config.ANTHROPIC_API_KEY:
182
+ raise RuntimeError("Missing ANTHROPIC_API_KEY env var")
183
+ if anthropic is None:
184
+ raise RuntimeError(f"Missing `anthropic` package. Import error: {_anthropic_import_error}")
185
+
186
+ client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
187
+
188
+ fast_kwargs = {}
189
+ if getattr(config, "ANTHROPIC_FAST_MODE_ENABLED", False):
190
+ fast_kwargs = {
191
+ "extra_headers": {"anthropic-beta": getattr(config, "ANTHROPIC_FAST_MODE_BETA", "fast-mode-2026-02-01")},
192
+ "extra_body": {"speed": getattr(config, "ANTHROPIC_FAST_MODE_SPEED", "fast")},
193
+ }
194
+
195
+ try:
196
+ with client.messages.stream(
197
+ model=config.ANTHROPIC_MODEL,
198
+ max_tokens=max_tokens,
199
+ temperature=temperature,
200
+ system=system,
201
+ messages=[{"role": "user", "content": prompt}],
202
+ timeout=timeout,
203
+ **fast_kwargs,
204
+ ) as stream:
205
+ for event in stream:
206
+ if getattr(event, "type", None) == "content_block_delta":
207
+ delta = getattr(event, "delta", None)
208
+ chunk = getattr(delta, "text", None) if delta is not None else None
209
+ if chunk:
210
+ yield str(chunk)
211
+ except Exception:
212
+ # Retry without fast-mode params.
213
+ with client.messages.stream(
214
+ model=config.ANTHROPIC_MODEL,
215
+ max_tokens=max_tokens,
216
+ temperature=temperature,
217
+ system=system,
218
+ messages=[{"role": "user", "content": prompt}],
219
+ timeout=timeout,
220
+ ) as stream:
221
+ for event in stream:
222
+ if getattr(event, "type", None) == "content_block_delta":
223
+ delta = getattr(event, "delta", None)
224
+ chunk = getattr(delta, "text", None) if delta is not None else None
225
+ if chunk:
226
+ yield str(chunk)
227
+
228
+
229
+ def openai_health(timeout: int = 10) -> dict:
230
+ """Return basic health info for an OpenAI-compatible endpoint."""
231
+ try:
232
+ headers = {"Authorization": f"Bearer {config.OPENAI_API_KEY}"} if config.OPENAI_API_KEY else {}
233
+ data = _get_json_url(f"{config.OPENAI_BASE_URL}/models", headers=headers, timeout=timeout)
234
+ ids = [m.get("id") for m in (data.get("data") or []) if isinstance(m, dict)]
235
+ return {"ok": True, "reachable": True, "models": ids[:25]}
236
+ except Exception as e:
237
+ return {"ok": False, "reachable": False, "models": [], "error": str(e)}
238
+
239
+
240
+ def anthropic_health() -> dict:
241
+ """Return basic health info for Anthropic configuration.
242
+
243
+ Anthropic does not expose a simple unauthenticated models endpoint like OpenAI.
244
+ We keep this as a lightweight config check.
245
+ """
246
+ ok = bool(getattr(config, "ANTHROPIC_API_KEY", "") and getattr(config, "ANTHROPIC_MODEL", ""))
247
+ return {"ok": ok, "reachable": ok, "model": getattr(config, "ANTHROPIC_MODEL", "")}
anupa/backend/primitive_widget_map.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Primitive-driven widget contracts, validator, and Claude-quality fallback templates.
2
+
3
+ Each primitive in primitives.json defines:
4
+ - required_components: HTML signals that must appear in generated widget.
5
+ - forbidden_components: signals that must NOT appear.
6
+ - extra_context: injected into build_widget_prompt() to steer the model.
7
+ - fallback_type: which deterministic template to render if model violates contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import html as _html_mod
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .widget_prompt import inject_design_system
19
+
20
+ # ── Load primitives from JSON ──────────────────────────────────────────────
21
+
22
+ _PRIMITIVES_PATH = Path(__file__).resolve().parent / "primitives.json"
23
+
24
+ def _load_primitives() -> dict[str, Any]:
25
+ try:
26
+ return json.loads(_PRIMITIVES_PATH.read_text(encoding="utf-8"))
27
+ except Exception:
28
+ return {}
29
+
30
+ PRIMITIVES: dict[str, Any] = _load_primitives()
31
+
32
+ _DEFAULT_SPEC: dict[str, Any] = {
33
+ "required_components": [],
34
+ "forbidden_components": [],
35
+ "extra_context": "",
36
+ "fallback_type": "generic",
37
+ }
38
+
39
+
40
+ def get_primitive_spec(strategy_id: str) -> dict[str, Any]:
41
+ return PRIMITIVES.get(strategy_id, _DEFAULT_SPEC)
42
+
43
+
44
+ # ── Component signal registry ──────────────────────────────────────────────
45
+
46
+ _COMPONENT_SIGNALS: dict[str, list[str]] = {
47
+ "range_slider": ['type="range"', "type='range'"],
48
+ "calculator": ["function calc(", "oninput=\"calc(", "oninput='calc("],
49
+ "comparison_cards": ["class=\"raised", "class='raised", "comparison", "highlight"],
50
+ "tabs": ["class=\"tab", "class='tab", ".panel"],
51
+ "step_navigator": ["step-row", "step-num", "step_row"],
52
+ "chart": ["<canvas", "Chart(", "new Chart"],
53
+ "insight_cards": ["class=\"card", "class='card", "card-title"],
54
+ "result_box": ["result-box", "result-val"],
55
+ "prompt_chips": ["pill", "sendPrompt"],
56
+ "insight_card": ["class=\"card", "class='card"],
57
+ "search": ['type="text"', 'oninput="filter', 'oninput=\'filter'],
58
+ "progress_bars": ["progress-bar", "progress-wrap"],
59
+ }
60
+
61
+ def _html_has(html: str, signals: list[str]) -> bool:
62
+ return any(s.lower() in html.lower() for s in signals)
63
+
64
+
65
+ def validate_widget_html(html: str, spec: dict[str, Any]) -> tuple[bool, str]:
66
+ """Return (valid, reason). Invalid means model violated primitive contract."""
67
+ if not html:
68
+ return False, "empty_html"
69
+ for comp in spec.get("required_components", []):
70
+ sigs = _COMPONENT_SIGNALS.get(comp, [comp])
71
+ if not _html_has(html, sigs):
72
+ return False, f"missing_required:{comp}"
73
+ for comp in spec.get("forbidden_components", []):
74
+ sigs = _COMPONENT_SIGNALS.get(comp, [comp])
75
+ if _html_has(html, sigs):
76
+ return False, f"forbidden_component:{comp}"
77
+ return True, ""
78
+
79
+
80
+ # ── Escape helper ──────────────────────────────────────────────────────────
81
+
82
+ def _e(s: str) -> str:
83
+ return _html_mod.escape(str(s or ""), quote=True)
84
+
85
+
86
+ # ── Fallback template helpers ──────────────────────────────────────────────
87
+
88
+ def _parse_bullets(response: str) -> list[dict[str, str]]:
89
+ """Extract bullet points as {title, body} dicts."""
90
+ lines = response.split("\n")
91
+ items: list[dict[str, str]] = []
92
+ for line in lines:
93
+ stripped = line.strip()
94
+ for prefix in ("-", "•", "*", "→"):
95
+ if stripped.startswith(prefix):
96
+ text = stripped[len(prefix):].strip()
97
+ if ":" in text:
98
+ parts = text.split(":", 1)
99
+ items.append({"title": parts[0].strip(), "body": parts[1].strip()})
100
+ else:
101
+ items.append({"title": text[:50], "body": text})
102
+ break
103
+ if len(items) >= 6:
104
+ break
105
+ return items or [{"title": "Key Point", "body": response[:200]}]
106
+
107
+
108
+ def _parse_steps(response: str) -> list[dict[str, str]]:
109
+ """Extract numbered steps as {title, body} dicts."""
110
+ lines = response.split("\n")
111
+ items: list[dict[str, str]] = []
112
+ for line in lines:
113
+ stripped = line.strip()
114
+ m = re.match(r"^(\d+)[.)]\s+(.+)$", stripped)
115
+ if m:
116
+ text = m.group(2).strip()
117
+ if ":" in text:
118
+ parts = text.split(":", 1)
119
+ items.append({"title": parts[0].strip(), "body": parts[1].strip()})
120
+ else:
121
+ items.append({"title": f"Step {m.group(1)}", "body": text})
122
+ if len(items) >= 8:
123
+ break
124
+ return items or [{"title": "Step 1", "body": response[:200]}]
125
+
126
+
127
+ def _parse_questions(response: str) -> list[str]:
128
+ """Extract clarifying questions from response text."""
129
+ lines = response.split("\n")
130
+ qs: list[str] = []
131
+ for line in lines:
132
+ stripped = line.strip()
133
+ stripped = re.sub(r"^[-•*\d.)]+\s*", "", stripped)
134
+ if "?" in stripped and len(stripped) > 10:
135
+ qs.append(stripped)
136
+ if len(qs) >= 4:
137
+ break
138
+ return qs or ["Can you tell me more about what you're looking for?"]
139
+
140
+
141
+ def _parse_sections(response: str) -> list[dict[str, str]]:
142
+ """Split response into 2-3 labelled sections for tabs."""
143
+ paragraphs = [p.strip() for p in re.split(r"\n{2,}", response) if p.strip()]
144
+ if len(paragraphs) >= 3:
145
+ return [
146
+ {"label": "Summary", "body": paragraphs[0]},
147
+ {"label": "Details", "body": " ".join(paragraphs[1:-1])},
148
+ {"label": "Takeaways", "body": paragraphs[-1]},
149
+ ]
150
+ if len(paragraphs) == 2:
151
+ return [
152
+ {"label": "Overview", "body": paragraphs[0]},
153
+ {"label": "Details", "body": paragraphs[1]},
154
+ ]
155
+ return [{"label": "Response", "body": response[:600]}]
156
+
157
+
158
+ def _parse_markdown_table(response: str) -> tuple[list[str], list[list[str]]] | None:
159
+ lines = [ln.strip() for ln in response.splitlines() if ln.strip()]
160
+ if len(lines) < 2:
161
+ return None
162
+
163
+ def split_row(line: str) -> list[str]:
164
+ txt = line.strip()
165
+ if txt.startswith("|"):
166
+ txt = txt[1:]
167
+ if txt.endswith("|"):
168
+ txt = txt[:-1]
169
+ return [c.strip() for c in txt.split("|")]
170
+
171
+ def is_sep(line: str) -> bool:
172
+ cells = split_row(line)
173
+ if not cells:
174
+ return False
175
+ return all(bool(re.match(r"^:?-{3,}:?$", c)) for c in cells)
176
+
177
+ for i in range(len(lines) - 1):
178
+ head_ln = lines[i]
179
+ sep_ln = lines[i + 1]
180
+ if "|" not in head_ln or not is_sep(sep_ln):
181
+ continue
182
+ cols = split_row(head_ln)
183
+ if not cols:
184
+ continue
185
+
186
+ rows: list[list[str]] = []
187
+ for ln in lines[i + 2:]:
188
+ if "|" not in ln:
189
+ break
190
+ row = split_row(ln)
191
+ if not row:
192
+ break
193
+ while len(row) < len(cols):
194
+ row.append("")
195
+ rows.append(row[: len(cols)])
196
+ if rows:
197
+ return cols, rows
198
+ return None
199
+
200
+
201
+ # ── Fallback template builders ─────────────────────────────────────────────
202
+
203
+ def _fallback_comparison(user_message: str, response: str) -> str:
204
+ parsed_table = _parse_markdown_table(response)
205
+ if parsed_table:
206
+ cols, rows = parsed_table
207
+ table_head = "".join(f"<th>{_e(c)}</th>" for c in cols)
208
+ body_rows = []
209
+ option_idx = 0
210
+ if cols:
211
+ c0 = cols[0].strip().lower()
212
+ if c0 in {"option", "plan", "item", "choice", "name"}:
213
+ option_idx = 0
214
+ for row in rows[:8]:
215
+ item_name = row[option_idx] if option_idx < len(row) else row[0]
216
+ cells = "".join(f"<td>{_e(v)}</td>" for v in row)
217
+ body_rows.append(
218
+ f"""<tr class="clickable"
219
+ onclick="sendPrompt('Tell me more about {_e(item_name)} from this comparison table')">{cells}</tr>"""
220
+ )
221
+ table_html = f"""
222
+ <table>
223
+ <thead><tr>{table_head}</tr></thead>
224
+ <tbody>{''.join(body_rows)}</tbody>
225
+ </table>
226
+ """
227
+ return f"""<html><head></head><body><div class="widget-root">
228
+ <div class="tabs">
229
+ <button class="tab active" onclick="showTab(this,'t-compare')">Compare</button>
230
+ <button class="tab" onclick="showTab(this,'t-verdict')">Verdict</button>
231
+ </div>
232
+ <div id="t-compare" class="panel active">
233
+ <div class="card">
234
+ <div class="card-title">Comparison</div>
235
+ {table_html}
236
+ </div>
237
+ </div>
238
+ <div id="t-verdict" class="panel">
239
+ <div class="card">
240
+ <div class="card-title">Verdict</div>
241
+ <div style="font-size:13px;line-height:1.7;color:var(--text)">{_e(response[:360])}</div>
242
+ </div>
243
+ <button class="ask-btn" onclick="sendPrompt('Based on this table, which option fits my goal best: {_e(user_message[:80])}?')">Ask about this ↗</button>
244
+ </div>
245
+ <script>
246
+ function showTab(btn,id){{
247
+ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
248
+ document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
249
+ btn.classList.add('active');
250
+ document.getElementById(id).classList.add('active');
251
+ }}
252
+ </script>
253
+ </div></body></html>"""
254
+
255
+ lines = [l.strip() for l in response.split("\n") if l.strip()]
256
+ nouns: list[str] = []
257
+ for line in lines:
258
+ m = re.search(r"\*\*(.+?)\*\*", line)
259
+ if m:
260
+ nouns.append(m.group(1))
261
+ if len(nouns) >= 2:
262
+ break
263
+ if len(nouns) < 2:
264
+ nouns = ["Option A", "Option B"]
265
+
266
+ bullets = _parse_bullets(response)
267
+ rows_a = bullets[:3]
268
+ rows_b = bullets[3:6] or bullets[:3]
269
+
270
+ card_a = "\n".join(
271
+ f'<div class="raised" style="margin-bottom:8px" onclick="sendPrompt(\'Tell me more about {_e(nouns[0])} — {_e(r["title"])}\')"><div style="font-size:12px;font-weight:500">{_e(r["title"])}</div><div style="font-size:11px;color:var(--text2);margin-top:3px">{_e(r["body"][:80])}</div></div>'
272
+ for r in rows_a
273
+ )
274
+ card_b = "\n".join(
275
+ f'<div class="raised" style="margin-bottom:8px" onclick="sendPrompt(\'Tell me more about {_e(nouns[1])} — {_e(r["title"])}\')"><div style="font-size:12px;font-weight:500">{_e(r["title"])}</div><div style="font-size:11px;color:var(--text2);margin-top:3px">{_e(r["body"][:80])}</div></div>'
276
+ for r in rows_b
277
+ )
278
+
279
+ return f"""<html><head></head><body><div class="widget-root">
280
+ <div class="tabs">
281
+ <button class="tab active" onclick="showTab(this,'t-compare')">Compare</button>
282
+ <button class="tab" onclick="showTab(this,'t-verdict')">Verdict</button>
283
+ </div>
284
+ <div id="t-compare" class="panel active">
285
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
286
+ <div>
287
+ <div class="card-title">{_e(nouns[0])}</div>
288
+ {card_a}
289
+ </div>
290
+ <div>
291
+ <div class="card-title">{_e(nouns[1])}</div>
292
+ {card_b}
293
+ </div>
294
+ </div>
295
+ </div>
296
+ <div id="t-verdict" class="panel">
297
+ <div class="card">
298
+ <div class="card-title">Verdict</div>
299
+ <div style="font-size:13px;line-height:1.7;color:var(--text)">{_e(response[:300])}</div>
300
+ </div>
301
+ <button class="ask-btn" onclick="sendPrompt('Which is better for me — {_e(nouns[0])} or {_e(nouns[1])}? My goal is...')">Ask about this ↗</button>
302
+ </div>
303
+ <script>
304
+ function showTab(btn,id){{
305
+ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
306
+ document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
307
+ btn.classList.add('active');
308
+ document.getElementById(id).classList.add('active');
309
+ }}
310
+ </script>
311
+ </div></body></html>"""
312
+
313
+
314
+ def _fallback_chart(user_message: str, response: str) -> str:
315
+ nums = re.findall(r"\b(\d+\.?\d*)\b", response)
316
+ labels_raw = re.findall(r"\b([A-Z][a-z]{2,}(?:\s[A-Z][a-z]+)?)\b", response)
317
+ values = [float(n) for n in nums[:8] if float(n) > 0][:8]
318
+ labels = list(dict.fromkeys(labels_raw))[:len(values)]
319
+ while len(labels) < len(values):
320
+ labels.append(f"Item {len(labels)+1}")
321
+ if not values:
322
+ values = [10, 20, 15, 30, 25]
323
+ labels = ["A", "B", "C", "D", "E"]
324
+
325
+ js_labels = json.dumps(labels)
326
+ js_values = json.dumps(values)
327
+
328
+ return f"""<html><head><script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script></head>
329
+ <body><div class="widget-root">
330
+ <div class="metric-grid" style="margin-bottom:12px">
331
+ <div class="metric"><div class="metric-lbl">Data points</div><div class="metric-val">{len(values)}</div></div>
332
+ <div class="metric"><div class="metric-lbl">Max value</div><div class="metric-val">{max(values):.1f}</div></div>
333
+ <div class="metric"><div class="metric-lbl">Average</div><div class="metric-val">{sum(values)/len(values):.1f}</div></div>
334
+ </div>
335
+ <div style="position:relative;height:220px">
336
+ <canvas id="ch"></canvas>
337
+ </div>
338
+ <button class="ask-btn" style="margin-top:12px" onclick="sendPrompt('Explain the trend shown in this chart for {_e(user_message[:60])}')">Ask about this ↗</button>
339
+ <script>
340
+ const dark=matchMedia('(prefers-color-scheme:dark)').matches;
341
+ const tc=dark?'#8d93aa':'#5a5f72';
342
+ const gc=dark?'rgba(255,255,255,0.06)':'rgba(0,0,0,0.06)';
343
+ new Chart(document.getElementById('ch'),{{
344
+ type:'bar',
345
+ data:{{labels:{js_labels},datasets:[{{data:{js_values},backgroundColor:'rgba(55,138,221,0.7)',borderRadius:6,borderSkipped:false}}]}},
346
+ options:{{responsive:true,maintainAspectRatio:false,plugins:{{legend:{{display:false}}}},scales:{{x:{{ticks:{{color:tc}},grid:{{color:gc}}}},y:{{ticks:{{color:tc}},grid:{{color:gc}}}}}}}}
347
+ }});
348
+ </script>
349
+ </div></body></html>"""
350
+
351
+
352
+ def _fallback_steps(user_message: str, response: str) -> str:
353
+ steps = _parse_steps(response)
354
+ rows = "\n".join(
355
+ f"""<div class="step-row" onclick="sendPrompt('Tell me more about step {i+1}: {_e(s['title'])}')">
356
+ <div class="step-num">{i+1}</div>
357
+ <div><div class="step-title">{_e(s['title'])}</div>
358
+ <div class="step-desc">{_e(s['body'][:100])}</div></div>
359
+ </div>"""
360
+ for i, s in enumerate(steps)
361
+ )
362
+ return f"""<html><head></head><body><div class="widget-root">
363
+ <div class="card">
364
+ <div class="card-title">{len(steps)} Steps</div>
365
+ {rows}
366
+ </div>
367
+ <button class="ask-btn" onclick="sendPrompt('I completed the steps for {_e(user_message[:60])}. What should I do next?')">What next ↗</button>
368
+ </div></body></html>"""
369
+
370
+
371
+ def _fallback_bullets(user_message: str, response: str) -> str:
372
+ items = _parse_bullets(response)
373
+ cards = "\n".join(
374
+ f"""<div class="raised" onclick="sendPrompt('Tell me more about: {_e(b['title'])}')">
375
+ <div style="font-size:11px;font-weight:600;color:var(--accent);margin-bottom:4px">#{i+1}</div>
376
+ <div style="font-size:13px;font-weight:500">{_e(b['title'])}</div>
377
+ <div style="font-size:12px;color:var(--text2);margin-top:4px">{_e(b['body'][:100])}</div>
378
+ </div>"""
379
+ for i, b in enumerate(items)
380
+ )
381
+ return f"""<html><head></head><body><div class="widget-root">
382
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
383
+ {cards}
384
+ </div>
385
+ <button class="ask-btn" onclick="sendPrompt('Which of these points matters most for {_e(user_message[:60])}?')">Explore further ↗</button>
386
+ </div></body></html>"""
387
+
388
+
389
+ def _fallback_narrative(user_message: str, response: str) -> str:
390
+ sections = _parse_sections(response)
391
+ tabs_html = "\n".join(
392
+ f'<button class="tab{" active" if i==0 else ""}" onclick="showTab(this,\'s{i}\')">{_e(s["label"])}</button>'
393
+ for i, s in enumerate(sections)
394
+ )
395
+ panels_html = "\n".join(
396
+ f'<div id="s{i}" class="panel{" active" if i==0 else ""}"><div class="card"><div style="font-size:13px;line-height:1.75;color:var(--text)">{_e(s["body"])}</div></div></div>'
397
+ for i, s in enumerate(sections)
398
+ )
399
+ return f"""<html><head></head><body><div class="widget-root">
400
+ <div class="tabs">{tabs_html}</div>
401
+ {panels_html}
402
+ <button class="ask-btn" onclick="sendPrompt('I want to explore {_e(user_message[:60])} in more detail')">Go deeper ↗</button>
403
+ <script>
404
+ function showTab(btn,id){{
405
+ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
406
+ document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
407
+ btn.classList.add('active');
408
+ document.getElementById(id).classList.add('active');
409
+ }}
410
+ </script>
411
+ </div></body></html>"""
412
+
413
+
414
+ def _fallback_concise(user_message: str, response: str) -> str:
415
+ short = response.strip()[:280]
416
+ return f"""<html><head></head><body><div class="widget-root">
417
+ <div class="result-box">
418
+ <div class="result-lbl">Answer</div>
419
+ <div style="font-size:15px;font-weight:500;line-height:1.6;color:var(--text)">{_e(short)}</div>
420
+ </div>
421
+ <button class="ask-btn" onclick="sendPrompt('Can you explain this in more detail: {_e(user_message[:60])}')">Explain more ↗</button>
422
+ </div></body></html>"""
423
+
424
+
425
+ def _fallback_socratic(user_message: str, response: str) -> str:
426
+ questions = _parse_questions(response)
427
+ icons = ["🧮", "📈", "🎯", "💡", "🔍", "📊"]
428
+
429
+ options = "\n".join(
430
+ f"""
431
+ <div class="step-row" style="cursor:pointer" onclick="sendPrompt({json.dumps(q)})">
432
+ <div class="step-num">{icons[i % len(icons)]}</div>
433
+ <div>
434
+ <div class="step-title">{_e(q[:80])}</div>
435
+ <div class="step-desc" style="margin-top:3px">
436
+ {_e("Click to answer this and I will adapt the next explanation or calculator to that choice.")}
437
+ </div>
438
+ </div>
439
+ </div>"""
440
+ for i, q in enumerate(questions)
441
+ )
442
+
443
+ return f"""<html><head></head><body><div class="widget-root">
444
+ <div class="card">
445
+ <div class="card-title">Help me understand</div>
446
+ <div style="font-size:13px;color:var(--text2);margin-bottom:12px">
447
+ Click the option that best matches your situation. I will use your choice to decide whether to show a calculator, comparison table, or step-by-step guide next.
448
+ </div>
449
+ {options}
450
+ </div>
451
+ </div></body></html>"""
452
+
453
+
454
+ def _fallback_generic(user_message: str, response: str) -> str:
455
+ return _fallback_concise(user_message, response)
456
+
457
+
458
+ _FALLBACK_BUILDERS = {
459
+ "comparison": _fallback_comparison,
460
+ "chart": _fallback_chart,
461
+ "steps": _fallback_steps,
462
+ "bullets": _fallback_bullets,
463
+ "narrative": _fallback_narrative,
464
+ "concise": _fallback_concise,
465
+ "socratic": _fallback_socratic,
466
+ "generic": _fallback_generic,
467
+ }
468
+
469
+
470
+ def build_primitive_fallback(strategy_id: str, user_message: str, response: str) -> str:
471
+ """Build a deterministic Claude-quality fallback widget for the given primitive."""
472
+ spec = get_primitive_spec(strategy_id)
473
+ fallback_type = spec.get("fallback_type", "generic")
474
+ builder = _FALLBACK_BUILDERS.get(fallback_type, _fallback_generic)
475
+ raw_html = builder(user_message, response)
476
+ return inject_design_system(raw_html)
anupa/backend/primitives.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "comparison_table": {
3
+ "description": "Side-by-side comparison of 2 or more options with pros, cons, and recommendation.",
4
+ "required_components": ["comparison_cards", "tabs"],
5
+ "forbidden_components": ["range_slider", "calculator", "step_navigator"],
6
+ "extra_context": "Build: tabs at top (Compare / Details / Verdict) + 2-column comparison cards side by side. Highlight recommended option with accent border and 'Recommended' badge. Every card must call sendPrompt with that option's name and key metric. Add a Verdict tab with a clear winner summary and ask button.",
7
+ "fallback_type": "comparison"
8
+ },
9
+ "visualization": {
10
+ "description": "Chart or visual representation of data — bar, line, or distribution.",
11
+ "required_components": ["chart"],
12
+ "forbidden_components": ["range_slider", "step_navigator", "comparison_cards"],
13
+ "extra_context": "Build an enterprise mini-dashboard: (1) a metric cards row at top with key numbers, (2) a primary chart area, (3) a short insights section below. Prefer D3.js for charts (SVG) with a hover tooltip; use a line chart for time-series trends and a bar chart for comparisons. Add period toggle buttons (1Y/3Y/5Y/All) that filter the plotted data locally (no extra API calls). Include a brief explanatory paragraph above the chart and 2-4 insight bullets below it so the widget is a self-contained answer. Use only real numbers/entities from assistant_response — no placeholder data.",
14
+ "fallback_type": "chart"
15
+ },
16
+ "step_by_step": {
17
+ "description": "Numbered sequential steps — how-to guide or process walkthrough.",
18
+ "required_components": ["step_navigator"],
19
+ "forbidden_components": ["range_slider", "calculator", "comparison_cards", "chart"],
20
+ "extra_context": "Build: a step navigator showing all steps as clickable rows with step number circle, title, and brief description. Each step row calls sendPrompt('Tell me more about step N: [title]'). Show step count badge. Add a final ask button summarizing the process.",
21
+ "fallback_type": "steps"
22
+ },
23
+ "structured_bullets": {
24
+ "description": "3-5 key bullet points — structured summary with clear takeaways.",
25
+ "required_components": ["insight_cards"],
26
+ "forbidden_components": ["range_slider", "chart", "step_navigator"],
27
+ "extra_context": "Build: a grid of insight cards (2 per row) where each card shows one bullet point with an icon area, title extracted from the bullet, and body text. Each card is clickable and calls sendPrompt with that insight's title. Add a summary metric card at top if numbers are present.",
28
+ "fallback_type": "bullets"
29
+ },
30
+ "narrative_prose": {
31
+ "description": "Flowing paragraphs — explanatory or contextual narrative response.",
32
+ "required_components": ["tabs", "insight_card"],
33
+ "forbidden_components": ["range_slider", "step_navigator"],
34
+ "extra_context": "Build: tabs splitting the narrative into 2-3 logical sections (Summary / Details / Key Takeaways). Each tab shows a clean card with formatted text. Add key metric highlights as small badges if numbers appear. Final tab has an ask button for follow-up.",
35
+ "fallback_type": "narrative"
36
+ },
37
+ "concise_direct": {
38
+ "description": "Short direct answer — 1-3 sentences, no fluff.",
39
+ "required_components": ["result_box"],
40
+ "forbidden_components": ["range_slider", "chart", "step_navigator", "tabs"],
41
+ "extra_context": "Build: a single clean result card with a prominent headline answer and 1-2 supporting lines of context. Keep compact. Include one ask button below for follow-up exploration.",
42
+ "fallback_type": "concise"
43
+ },
44
+ "socratic_questions": {
45
+ "description": "Clarifying questions to understand user intent before answering.",
46
+ "required_components": ["prompt_chips"],
47
+ "forbidden_components": ["range_slider", "calculator", "chart", "step_navigator"],
48
+ "extra_context": "Build: a card with a brief acknowledgement line, then 2-4 clickable question chips styled as pills. Each chip calls sendPrompt with that exact clarifying answer as a full natural language sentence. No extra controls needed.",
49
+ "fallback_type": "socratic"
50
+ }
51
+ }
anupa/backend/server.py ADDED
@@ -0,0 +1,650 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP server and request handlers for the Adaptive Presentation Engine backend."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import time
7
+ from http.server import HTTPServer, BaseHTTPRequestHandler
8
+ from socketserver import ThreadingMixIn
9
+
10
+ import numpy as np
11
+
12
+ from . import config, llm
13
+ from .engine import engine, USERB_ID
14
+ from .widget_prompt import (
15
+ estimate_widget_height,
16
+ inject_design_system,
17
+ )
18
+ from .combined_prompt import (
19
+ build_combined_system_prompt,
20
+ build_combined_user_prompt,
21
+ parse_combined_output,
22
+ )
23
+ from .utils import (
24
+ fast_valence,
25
+ enforce_response,
26
+ detect_format_override,
27
+ detect_explore_trigger,
28
+ negative_strength,
29
+ )
30
+
31
+ def _looks_truncated_widget_html(html: str) -> bool:
32
+ """Heuristic check for obviously cut-off widget HTML."""
33
+ if not html:
34
+ return True
35
+ lower = html.lower()
36
+ # Missing critical closures often indicates token truncation.
37
+ if lower.count("<style") > lower.count("</style>"):
38
+ return True
39
+ if lower.count("<script") > lower.count("</script>"):
40
+ return True
41
+ if lower.count("<body") > lower.count("</body>"):
42
+ return True
43
+ if lower.count("<html") > lower.count("</html>"):
44
+ return True
45
+ # Rarely, response ends mid-token; catch abrupt ending.
46
+ if re.search(r"[<{(]$", html.strip()):
47
+ return True
48
+ return False
49
+
50
+
51
+ def _bool_env(name: str, default: bool) -> bool:
52
+ raw = os.getenv(name)
53
+ if raw is None:
54
+ return default
55
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
56
+
57
+
58
+ class Handler(BaseHTTPRequestHandler):
59
+ def log_message(self, *a):
60
+ pass
61
+
62
+ def _cors(self):
63
+ self.send_header("Access-Control-Allow-Origin", "*")
64
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
65
+ self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
66
+
67
+ def _json(self, data, status=200):
68
+ body = json.dumps(data).encode()
69
+ self.send_response(status)
70
+ self.send_header("Content-Type", "application/json")
71
+ self.send_header("Content-Length", len(body))
72
+ self._cors()
73
+ self.end_headers()
74
+ self.wfile.write(body)
75
+
76
+ def _html(self):
77
+ try:
78
+ html = config.INDEX_HTML.read_bytes()
79
+ except FileNotFoundError:
80
+ self.send_response(404); self.end_headers(); return
81
+ self.send_response(200)
82
+ self.send_header("Content-Type", "text/html; charset=utf-8")
83
+ self.send_header("Content-Length", len(html))
84
+ self.end_headers()
85
+ self.wfile.write(html)
86
+
87
+ def _body(self):
88
+ n = int(self.headers.get("Content-Length", 0))
89
+ if not n:
90
+ return {}
91
+ raw = self.rfile.read(n)
92
+ if not raw:
93
+ return {}
94
+ try:
95
+ return json.loads(raw)
96
+ except json.JSONDecodeError:
97
+ # Don't crash the handler thread; return a sentinel the caller can handle.
98
+ try:
99
+ preview = raw[:500].decode("utf-8", errors="replace")
100
+ except Exception:
101
+ preview = repr(raw[:200])
102
+ return {"__invalid_json__": True, "__raw_preview__": preview}
103
+
104
+ def do_OPTIONS(self):
105
+ self.send_response(204)
106
+ self._cors()
107
+ self.end_headers()
108
+
109
+ def do_GET(self):
110
+ p = self.path.split("?")[0]
111
+ if p == "/":
112
+ self._html(); return
113
+
114
+ if p == "/api/health":
115
+ if config.LLM_MODE == "openai_compat":
116
+ h = llm.openai_health()
117
+ self._json({"server": "ok", "mode": config.LLM_MODE, "openai_base_url": config.OPENAI_BASE_URL, "model": config.OPENAI_MODEL, **h})
118
+ elif config.LLM_MODE == "anthropic":
119
+ h = llm.anthropic_health()
120
+ self._json({"server": "ok", "mode": config.LLM_MODE, **h})
121
+ else:
122
+ self._json({"server": "ok", "mode": config.LLM_MODE, "ok": False, "reachable": False, "error": "Unsupported LLM_MODE (expected openai_compat or anthropic)"})
123
+ return
124
+
125
+ if p == "/api/state":
126
+ uid = self.path.split("uid=")[-1] if "uid=" in self.path else "demo"
127
+ user = engine.get_user(uid)
128
+ x = np.ones(config.D) * 0.5
129
+ ub = engine.get_user(USERB_ID)
130
+ self._json({
131
+ "posterior": engine.user_posterior(uid, x),
132
+ "global": engine.global_posterior(x),
133
+ "userb": engine.posterior_summary(ub["mu"], ub["sigma_inv"], x),
134
+ "global_n": engine.global_n,
135
+ "n_users": len(engine.users),
136
+ "msg_count": user["msg_count"],
137
+ }); return
138
+
139
+ self.send_response(404); self.end_headers()
140
+
141
+ def do_POST(self):
142
+ p = self.path.split("?")[0]
143
+ body = self._body()
144
+ if isinstance(body, dict) and body.get("__invalid_json__"):
145
+ self._json({"error": "invalid_json", "preview": body.get("__raw_preview__", "")}, 400)
146
+ return
147
+
148
+ if p == "/api/chat_plain":
149
+ uid = body.get("uid", "demo") + "_plain"
150
+ msg = body.get("message", "").strip()
151
+ if not msg:
152
+ self._json({"error": "empty message"}, 400); return
153
+
154
+ user = engine.get_user(uid)
155
+
156
+ # Build a simple conversation prompt (no bandit, no enforced format).
157
+ ctx = []
158
+ for t in user["history"][-6:]:
159
+ ctx += [f"User: {t['user']}", f"Assistant: {t['assistant']}"]
160
+ ctx.append(f"User: {msg}")
161
+ prompt = "\n".join(ctx)
162
+
163
+ system = "You are a helpful AI assistant."
164
+
165
+ try:
166
+ base_mode = (config.BASELINE_LLM_MODE or config.LLM_MODE).lower()
167
+ if base_mode == "openai_compat":
168
+ response, elapsed, mode = llm.call_openai_compat(prompt, system, timeout=120)
169
+ elif base_mode == "anthropic":
170
+ response, elapsed, mode = llm.call_anthropic(prompt, system, timeout=120)
171
+ else:
172
+ raise RuntimeError("Unsupported BASELINE_LLM_MODE (expected openai_compat or anthropic)")
173
+ except Exception as e:
174
+ self._json({"error": f"LLM error: {str(e)}"}, 500)
175
+ return
176
+
177
+ if not response:
178
+ self._json({"error": "LLM returned empty response. Check model/service."}, 500)
179
+ return
180
+
181
+ user["history"].append({"user": msg, "assistant": response})
182
+ user["history"] = user["history"][-20:]
183
+
184
+ self._json({
185
+ "response": response,
186
+ "elapsed": elapsed,
187
+ "llm_mode": mode,
188
+ }); return
189
+
190
+ if p == "/api/chat":
191
+ uid = body.get("uid", "demo")
192
+ msg = body.get("message", "").strip()
193
+ if not msg:
194
+ self._json({"error": "empty message"}, 400); return
195
+
196
+ user = engine.get_user(uid)
197
+
198
+ # Auto-reward previous turn using valence heuristic.
199
+ ev = fast_valence(msg, user["last_response"])
200
+ auto_detected = False
201
+ auto_r = None
202
+
203
+ if user["last_response"] and user["last_x"] is not None and user["last_strategy"]:
204
+ reward = float(np.clip(0.5 + 0.45*ev["pos"] - 0.45*ev["neg"], 0.05, 0.95))
205
+ engine.update(uid, user["last_strategy"], np.array(user["last_x"]), reward)
206
+ auto_detected = True
207
+ auto_r = reward
208
+
209
+ # --- NEW: explicit overrides + corrective exploration ---
210
+ explicit = detect_format_override(msg, config.STRATEGY_NAMES)
211
+ force_explore = bool(detect_explore_trigger(msg) or (ev.get("neg", 0.0) >= config.NEG_EXPLORE_THRESHOLD))
212
+ neg_s = negative_strength(ev)
213
+
214
+ strat, scores, x, prev = engine.select(
215
+ uid, msg,
216
+ force_explore=force_explore,
217
+ neg_strength=neg_s,
218
+ explicit_strategy=explicit,
219
+ )
220
+
221
+ format_rule = config.STRATEGIES.get(strat, "Be helpful and clear.")
222
+
223
+ # ── Single-call combined prompt (Claude-style) ─────────────────
224
+ combined_max_tokens = getattr(config, "COMBINED_MAX_TOKENS", 2800)
225
+ # Keep the UI responsive by failing fast by default.
226
+ combined_timeout = getattr(config, "COMBINED_TIMEOUT_SECONDS", 30)
227
+
228
+ combined_system = build_combined_system_prompt(
229
+ strategy_id=strat,
230
+ format_rule=format_rule,
231
+ primitive_extra_context="",
232
+ user_message=msg,
233
+ forbidden_components=None,
234
+ required_components=None,
235
+ )
236
+ combined_prompt = build_combined_user_prompt(
237
+ user_message=msg,
238
+ history=user["history"],
239
+ )
240
+
241
+ try:
242
+ adapt_mode = (config.ADAPTIVE_LLM_MODE or config.LLM_MODE).lower()
243
+ if adapt_mode == "openai_compat":
244
+ raw_combined, elapsed, mode = llm.call_openai_compat(
245
+ combined_prompt,
246
+ combined_system,
247
+ timeout=combined_timeout,
248
+ max_tokens=combined_max_tokens,
249
+ temperature=0.2,
250
+ )
251
+ elif adapt_mode == "anthropic":
252
+ raw_combined, elapsed, mode = llm.call_anthropic(
253
+ combined_prompt,
254
+ combined_system,
255
+ timeout=combined_timeout,
256
+ max_tokens=combined_max_tokens,
257
+ temperature=0.2,
258
+ )
259
+ else:
260
+ raise RuntimeError("Unsupported ADAPTIVE_LLM_MODE (expected openai_compat or anthropic)")
261
+ except Exception as e:
262
+ self._json({"error": f"LLM error: {str(e)}"}, 500)
263
+ return
264
+
265
+ if not raw_combined:
266
+ self._json({"error": "LLM returned empty response. Check model/service."}, 500)
267
+ return
268
+
269
+ # Parse combined output into text response + widget payload (HTML or JSON schema).
270
+ response, widget_payload_raw = parse_combined_output(raw_combined)
271
+
272
+ if not response:
273
+ response = raw_combined.strip()
274
+
275
+ if config.STRICT_PRIMITIVES:
276
+ response = enforce_response(strat, response)
277
+
278
+ # ── Validate widget from single call ───────────────────────────
279
+ widget_html = ""
280
+ widget_schema = ""
281
+ widget_height = 0
282
+ widget_debug = ""
283
+ widget_mode = getattr(config, "WIDGET_MODE", "json").strip().lower()
284
+
285
+ if widget_payload_raw:
286
+ if widget_mode == "json":
287
+ widget_schema = widget_payload_raw
288
+ widget_debug = widget_debug or "combined_schema_ok"
289
+ else:
290
+ if _looks_truncated_widget_html(widget_payload_raw):
291
+ widget_debug = "combined_widget_truncated"
292
+ else:
293
+ widget_html = widget_payload_raw
294
+ widget_height = estimate_widget_height(widget_payload_raw)
295
+ widget_debug = widget_debug or "combined_widget_ok"
296
+ else:
297
+ widget_debug = widget_debug or ("combined_no_schema" if widget_mode == "json" else "combined_no_widget_tag")
298
+ raw_preview = (raw_combined or "")[:800]
299
+ if widget_mode != "json":
300
+ # Never return a blank iframe: serve a safe interactive placeholder mini-app.
301
+ placeholder = f"""
302
+ <html><head></head><body>
303
+ <div class="widget-root card">
304
+ <div class="card-title">Interactive widget</div>
305
+ <div style="color:var(--text2);font-size:13px;line-height:1.6">
306
+ I couldn't generate a full widget for this turn. This placeholder stays interactive and can request the missing data.
307
+ </div>
308
+ <div class="ctrl-row" style="margin-top:12px">
309
+ <div class="ctrl-lbl">Assumption</div>
310
+ <input id="assump" type="range" min="0" max="100" step="1" value="50" style="flex:1" />
311
+ <div class="ctrl-val" id="assumpv">50</div>
312
+ </div>
313
+ <div id="chart" class="raised" style="height:260px;margin-top:10px"></div>
314
+ <div class="btn-group" style="margin-top:10px">
315
+ <button class="btn" id="ask_range">Ask for date range</button>
316
+ <button class="btn" id="ask_source">Ask for data source</button>
317
+ </div>
318
+ </div>
319
+ <script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
320
+ <script>
321
+ const data = [
322
+ {{name:'Series A', base: 100}},
323
+ {{name:'Series B', base: 100}}
324
+ ];
325
+ const state = {{assumption: 50}};
326
+ function compute(state, data) {{
327
+ const k = (state.assumption/50);
328
+ return {{
329
+ labels: data.map(d=>d.name),
330
+ values: data.map((d,i)=>Math.round(d.base*(i? (1.15*k):(1.05*k))))
331
+ }};
332
+ }}
333
+ let chart;
334
+ function render() {{
335
+ const c = compute(state, data);
336
+ document.getElementById('assumpv').textContent = String(state.assumption);
337
+ if (!chart) chart = echarts.init(document.getElementById('chart'));
338
+ chart.setOption({{
339
+ backgroundColor:'transparent',
340
+ tooltip:{{trigger:'axis'}},
341
+ xAxis:{{type:'category',data:c.labels,axisLabel:{{color:'#5a5f72'}}}},
342
+ yAxis:{{type:'value',axisLabel:{{color:'#5a5f72'}}}},
343
+ series:[{{type:'bar',data:c.values}}]
344
+ }});
345
+ }}
346
+ document.getElementById('assump').addEventListener('input', (e)=>{{ state.assumption = +e.target.value; render(); }});
347
+ document.getElementById('ask_range').onclick = ()=> sendPrompt('Provide a date range to plot (e.g., 2020-01-01 to 2024-12-31).');
348
+ document.getElementById('ask_source').onclick = ()=> sendPrompt('Which data source should we use for SPY and QQQ prices (yfinance, stooq, other)?');
349
+ render();
350
+ </script>
351
+ </body></html>
352
+ """.strip()
353
+ widget_html = inject_design_system(placeholder)
354
+ widget_height = estimate_widget_height(widget_html)
355
+ widget_debug = "fallback_widget_generated"
356
+
357
+ # No primitive fallback. If widget is missing/invalid, return text-only.
358
+
359
+ user["history"].append({"user": msg, "assistant": response})
360
+ user["history"] = user["history"][-20:]
361
+ user["last_message"] = msg
362
+ user["last_response"] = response
363
+ user["last_strategy"] = strat
364
+ user["last_x"] = x.tolist()
365
+ user["msg_count"] += 1
366
+
367
+ ub = engine.get_user(USERB_ID)
368
+ self._json({
369
+ "response": response,
370
+ "strategy": strat,
371
+ "prev_strategy": prev,
372
+ "explicit": explicit,
373
+ "force_explore": force_explore and (explicit is None),
374
+ "instruction": config.STRATEGIES[strat],
375
+ "elapsed": elapsed,
376
+ "llm_mode": mode,
377
+ "scores": {k: round(v, 4) for k, v in scores.items()},
378
+ "x_vec": x.tolist(),
379
+ "posterior": engine.user_posterior(uid, x),
380
+ "global": engine.global_posterior(x),
381
+ "userb": engine.posterior_summary(ub["mu"], ub["sigma_inv"], x),
382
+ "global_n": engine.global_n,
383
+ "auto_detected": auto_detected,
384
+ "auto_r": auto_r,
385
+ "auto_reason": ev["reason"],
386
+ "widget_html": widget_html,
387
+ "widget_schema": widget_schema,
388
+ "widget_height": widget_height,
389
+ "widget_debug": widget_debug,
390
+ "widget_raw_preview": raw_preview if not (widget_html or widget_schema) else "",
391
+ }); return
392
+
393
+ if p == "/api/reward":
394
+ uid = body.get("uid", "demo")
395
+ strategy = body.get("strategy")
396
+ x_vec = body.get("x_vec")
397
+ reward = float(body.get("reward", 0.5))
398
+ if strategy not in config.STRATEGY_NAMES or x_vec is None:
399
+ self._json({"error": "bad request"}, 400); return
400
+ x = np.array(x_vec, dtype=float)
401
+ engine.update(uid, strategy, x, reward)
402
+ ub = engine.get_user(USERB_ID)
403
+ self._json({
404
+ "posterior": engine.user_posterior(uid, x),
405
+ "global": engine.global_posterior(x),
406
+ "userb": engine.posterior_summary(ub["mu"], ub["sigma_inv"], x),
407
+ "global_n": engine.global_n,
408
+ }); return
409
+
410
+ if p == "/api/preference":
411
+ uid = body.get("uid", "demo")
412
+ strategies = body.get("strategies", [])
413
+ lock = bool(body.get("lock", False))
414
+ engine.apply_preferences(uid, strategies, lock=lock)
415
+ self._json({"posterior": engine.user_posterior(uid)}); return
416
+
417
+ if p == "/api/reset":
418
+ engine.reset_user(body.get("uid", "demo"))
419
+ self._json({"ok": True}); return
420
+
421
+ # ── NEW: adaptive streaming endpoint (Claude-like) ────────────────
422
+ if p == "/api/chat_stream":
423
+ uid = body.get("uid", "demo")
424
+ msg = body.get("message", "").strip()
425
+ if not msg:
426
+ self._json({"error": "empty message"}, 400); return
427
+
428
+ user = engine.get_user(uid)
429
+
430
+ # Auto-reward previous turn using valence heuristic.
431
+ ev = fast_valence(msg, user["last_response"])
432
+ auto_detected = False
433
+ auto_r = None
434
+
435
+ if user["last_response"] and user["last_x"] is not None and user["last_strategy"]:
436
+ reward = float(np.clip(0.5 + 0.45*ev["pos"] - 0.45*ev["neg"], 0.05, 0.95))
437
+ engine.update(uid, user["last_strategy"], np.array(user["last_x"]), reward)
438
+ auto_detected = True
439
+ auto_r = reward
440
+
441
+ explicit = detect_format_override(msg, config.STRATEGY_NAMES)
442
+ force_explore = bool(detect_explore_trigger(msg) or (ev.get("neg", 0.0) >= config.NEG_EXPLORE_THRESHOLD))
443
+ neg_s = negative_strength(ev)
444
+
445
+ strat, scores, x, prev = engine.select(
446
+ uid, msg,
447
+ force_explore=force_explore,
448
+ neg_strength=neg_s,
449
+ explicit_strategy=explicit,
450
+ )
451
+
452
+ format_rule = config.STRATEGIES.get(strat, "Be helpful and clear.")
453
+
454
+ combined_max_tokens = getattr(config, "COMBINED_MAX_TOKENS", 2800)
455
+ combined_timeout = getattr(config, "COMBINED_TIMEOUT_SECONDS", 30)
456
+
457
+ combined_system = build_combined_system_prompt(
458
+ strategy_id=strat,
459
+ format_rule=format_rule,
460
+ primitive_extra_context="",
461
+ user_message=msg,
462
+ forbidden_components=None,
463
+ required_components=None,
464
+ )
465
+ combined_prompt = build_combined_user_prompt(
466
+ user_message=msg,
467
+ history=user["history"],
468
+ )
469
+
470
+ adapt_mode = (config.ADAPTIVE_LLM_MODE or config.LLM_MODE).lower()
471
+
472
+ # Start NDJSON stream.
473
+ self.send_response(200)
474
+ self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
475
+ self._cors()
476
+ self.end_headers()
477
+
478
+ ub = engine.get_user(USERB_ID)
479
+
480
+ def send_nd(evt: dict):
481
+ try:
482
+ line = json.dumps(evt, ensure_ascii=False)
483
+ self.wfile.write(line.encode("utf-8") + b"\n")
484
+ self.wfile.flush()
485
+ except Exception:
486
+ pass
487
+
488
+ def send_done_error(err: str):
489
+ send_nd({
490
+ "type": "done",
491
+ "strategy": strat,
492
+ "elapsed": None,
493
+ "llm_mode": adapt_mode,
494
+ "response": "",
495
+ "widget_html": "",
496
+ "widget_schema": "",
497
+ "widget_height": 0,
498
+ "widget_debug": f"stream_error:{err}",
499
+ "error": err,
500
+ "force_explore": force_explore,
501
+ "scores": {k: round(v, 4) for k, v in scores.items()},
502
+ "x_vec": x.tolist(),
503
+ "posterior": engine.user_posterior(uid, x),
504
+ "global": engine.global_posterior(x),
505
+ "userb": engine.posterior_summary(ub["mu"], ub["sigma_inv"], x),
506
+ "global_n": engine.global_n,
507
+ "prev_strategy": prev,
508
+ "explicit": explicit,
509
+ "auto_detected": auto_detected,
510
+ "auto_r": auto_r,
511
+ "auto_reason": ev["reason"],
512
+ })
513
+
514
+ # Initial strategy event so UI updates immediately.
515
+ send_nd({
516
+ "type": "strategy",
517
+ "strategy": strat,
518
+ "instruction": config.STRATEGIES[strat],
519
+ "elapsed": None,
520
+ "force_explore": force_explore,
521
+ "scores": {k: round(v, 4) for k, v in scores.items()},
522
+ "x_vec": x.tolist(),
523
+ "posterior": engine.user_posterior(uid, x),
524
+ "global": engine.global_posterior(x),
525
+ "userb": engine.posterior_summary(ub["mu"], ub["sigma_inv"], x),
526
+ "global_n": engine.global_n,
527
+ "prev_strategy": prev,
528
+ "explicit": explicit,
529
+ "auto_detected": auto_detected,
530
+ "auto_r": auto_r,
531
+ "auto_reason": ev["reason"],
532
+ })
533
+
534
+ try:
535
+ if adapt_mode == "openai_compat":
536
+ raw_combined, elapsed, mode = llm.call_openai_compat(
537
+ combined_prompt,
538
+ combined_system,
539
+ timeout=combined_timeout,
540
+ max_tokens=combined_max_tokens,
541
+ temperature=0.2,
542
+ )
543
+ elif adapt_mode == "anthropic":
544
+ # Use non-streaming call with wall-clock timeout (more robust than the SDK stream
545
+ # when the provider stalls), then optionally "replay" deltas to the UI.
546
+ raw_combined, elapsed, mode = llm.call_anthropic(
547
+ combined_prompt,
548
+ combined_system,
549
+ timeout=combined_timeout,
550
+ max_tokens=combined_max_tokens,
551
+ temperature=0.2,
552
+ )
553
+ else:
554
+ raise RuntimeError("Unsupported ADAPTIVE_LLM_MODE (expected openai_compat or anthropic)")
555
+ except Exception as e:
556
+ msg = str(e).strip()
557
+ if not msg:
558
+ msg = repr(e)
559
+ send_done_error(f"LLM error ({type(e).__name__}): {msg}")
560
+ return
561
+
562
+ response, widget_payload_raw = parse_combined_output(raw_combined)
563
+ if not response:
564
+ response = raw_combined.strip()
565
+ if config.STRICT_PRIMITIVES:
566
+ response = enforce_response(strat, response)
567
+
568
+ widget_debug = "nonstream"
569
+
570
+ widget_html = ""
571
+ widget_schema = ""
572
+ widget_height = 0
573
+ widget_mode = getattr(config, "WIDGET_MODE", "json").strip().lower()
574
+ if widget_payload_raw:
575
+ if widget_mode == "json":
576
+ widget_schema = widget_payload_raw
577
+ else:
578
+ if _looks_truncated_widget_html(widget_payload_raw):
579
+ widget_debug = "stream_widget_truncated"
580
+ else:
581
+ widget_html = widget_payload_raw
582
+ widget_height = estimate_widget_height(widget_payload_raw)
583
+ else:
584
+ if widget_mode != "json":
585
+ placeholder = "<html><head></head><body><div class='widget-root card'><div class='card-title'>Interactive widget</div><div class='empty'>No widget returned; click to request missing data.</div><div class='btn-group'><button class='btn' onclick=\"sendPrompt('Provide a date range for the requested plot.')\">Ask for date range</button></div></div></body></html>"
586
+ widget_html = inject_design_system(placeholder)
587
+ widget_height = estimate_widget_height(widget_html)
588
+ widget_debug = "fallback_widget_generated"
589
+
590
+ # Replay deltas so the UI doesn't look frozen (best-effort).
591
+ # The final "done" still contains the full response/widget.
592
+ try:
593
+ if response:
594
+ for i in range(0, len(response), 180):
595
+ send_nd({"type": "response_delta", "delta": response[i : i + 180]})
596
+ payload = widget_schema if widget_mode == "json" else widget_html
597
+ if payload:
598
+ for i in range(0, len(payload), 900):
599
+ send_nd({"type": "widget_delta", "delta": payload[i : i + 900]})
600
+ except Exception:
601
+ pass
602
+
603
+ # Update history as in /api/chat.
604
+ user["history"].append({"user": msg, "assistant": response})
605
+ user["history"] = user["history"][-20:]
606
+ user["last_message"] = msg
607
+ user["last_response"] = response
608
+ user["last_strategy"] = strat
609
+ user["last_x"] = x.tolist()
610
+ user["msg_count"] += 1
611
+
612
+ send_nd({
613
+ "type": "done",
614
+ "strategy": strat,
615
+ "elapsed": elapsed,
616
+ "llm_mode": mode,
617
+ "response": response,
618
+ "widget_html": widget_html or "",
619
+ "widget_schema": widget_schema or "",
620
+ "widget_height": widget_height,
621
+ "widget_debug": widget_debug,
622
+ "force_explore": force_explore,
623
+ "scores": {k: round(v, 4) for k, v in scores.items()},
624
+ "x_vec": x.tolist(),
625
+ "posterior": engine.user_posterior(uid, x),
626
+ "global": engine.global_posterior(x),
627
+ "userb": engine.posterior_summary(ub["mu"], ub["sigma_inv"], x),
628
+ "global_n": engine.global_n,
629
+ "prev_strategy": prev,
630
+ "explicit": explicit,
631
+ "auto_detected": auto_detected,
632
+ "auto_r": auto_r,
633
+ "auto_reason": ev["reason"],
634
+ })
635
+ return
636
+
637
+ self.send_response(404); self.end_headers()
638
+
639
+
640
+ class ThreadedServer(ThreadingMixIn, HTTPServer):
641
+ daemon_threads = True
642
+
643
+
644
+ def run_server():
645
+ print("=" * 60)
646
+ print(f" http://localhost:5051 mode: {config.LLM_MODE}")
647
+ print("=" * 60)
648
+
649
+ PORT = int(os.getenv("PORT", "5051"))
650
+ ThreadedServer(("0.0.0.0", PORT), Handler).serve_forever()
anupa/backend/utils.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility helpers: math, response enforcement, and valence heuristics.
2
+
3
+ This module provides:
4
+ - sigmoid + uncertainty helpers
5
+ - lightweight valence detection (heuristic)
6
+ - format override + explore trigger detection
7
+ - response post-processing to enforce selected format
8
+ """
9
+
10
+ import json
11
+ import re
12
+ import math
13
+ import numpy as np
14
+
15
+
16
+ def sigmoid(x: float) -> float:
17
+ x = float(np.clip(x, -500, 500))
18
+ return 1.0 / (1.0 + math.exp(-x))
19
+
20
+
21
+ def mean_uncertainty(sigma_inv: np.ndarray) -> float:
22
+ prec = np.diag(sigma_inv)
23
+ return float(np.mean(1.0 / np.clip(prec, 1e-8, None)))
24
+
25
+
26
+ # --- Valence signals ---
27
+ _POS = re.compile(
28
+ r"\b(thank|thanks|great|perfect|awesome|love|helpful|useful|exactly|makes sense|clear|brilliant|nice|good|yes|correct|right)\b",
29
+ re.I,
30
+ )
31
+ _NEG = re.compile(
32
+ r"\b(wrong|incorrect|confused|confusing|not what|still don.t|don't understand|that.s not|try again|again\b|useless|unhelpful|bad|nope\b|nah\b|wtf\b|huh\??|noooo+)\b",
33
+ re.I,
34
+ )
35
+ _REPHRASE = re.compile(r"\b(what I mean|let me rephrase|I said|as I mentioned|again|once more)\b", re.I)
36
+
37
+
38
+ def fast_valence(message: str, prev_response: str) -> dict:
39
+ """Return heuristic valence and a human-readable reason string."""
40
+ if not prev_response:
41
+ return {"pos": 0.5, "neg": 0.1, "reason": "first message"}
42
+ pos_hits = len(_POS.findall(message))
43
+ neg_hits = len(_NEG.findall(message))
44
+ rephr = 1 if _REPHRASE.search(message) else 0
45
+ len_ratio = len(message) / max(len(prev_response), 1)
46
+ brevity_neg = 0.3 if (len_ratio < 0.08 and len(message) < 15) else 0.0
47
+
48
+ pos = float(np.clip(0.3 + 0.3 * pos_hits - 0.1 * neg_hits, 0.0, 1.0))
49
+ neg = float(np.clip(0.1 + 0.3 * neg_hits + 0.2 * rephr + brevity_neg, 0.0, 1.0))
50
+
51
+ reasons = []
52
+ if pos_hits:
53
+ reasons.append(f"{pos_hits} positive signal(s)")
54
+ if neg_hits:
55
+ reasons.append(f"{neg_hits} negative signal(s)")
56
+ if rephr:
57
+ reasons.append("rephrase")
58
+ if brevity_neg:
59
+ reasons.append("very short reply")
60
+ return {"pos": pos, "neg": neg, "reason": ", ".join(reasons) or "neutral"}
61
+
62
+
63
+ # --- Explicit format override detection (user mentions what they want) ---
64
+ _OVERRIDE_PATTERNS = [
65
+ ("structured_bullets", re.compile(r"\b(bullets?|bullet points?|list it|in bullets)\b", re.I)),
66
+ ("step_by_step", re.compile(r"\b(step by step|steps?|walk me through|procedure)\b", re.I)),
67
+ ("concise_direct", re.compile(r"\b(concise|short|tl;dr|tldr|in 1-3 sentences)\b", re.I)),
68
+ ("narrative_prose", re.compile(r"\b(paragraph|narrative|in prose|explain like a story)\b", re.I)),
69
+ ("socratic_questions", re.compile(r"\b(ask me|ask questions|clarifying questions?)\b", re.I)),
70
+ ("comparison_table", re.compile(r"\b(table|comparison table|pros and cons|compare|vs\.?|versus)\b", re.I)),
71
+ ("visualization", re.compile(r"\b(chart|plot|graph|visuali[sz]e|visualization|bar chart|pie chart)\b", re.I)),
72
+ ]
73
+
74
+
75
+ def detect_format_override(message: str, available: list[str]) -> str | None:
76
+ m = (message or "").strip().lower()
77
+ for strat, pat in _OVERRIDE_PATTERNS:
78
+ if strat in available and pat.search(m):
79
+ return strat
80
+ return None
81
+
82
+
83
+ # --- Explore triggers (force trying a different format) ---
84
+ _EXPLORE_TRIG = re.compile(r"\b(try again|different|another way|not this|still the same|nope\b|nah\b|noooo+)\b", re.I)
85
+
86
+
87
+ def detect_explore_trigger(message: str) -> bool:
88
+ return bool(_EXPLORE_TRIG.search((message or "").strip()))
89
+
90
+
91
+ def negative_strength(ev: dict) -> float:
92
+ """Map heuristic valence to a [0,1] strength scalar."""
93
+ if not ev:
94
+ return 0.0
95
+ return float(np.clip(ev.get("neg", 0.0), 0.0, 1.0))
96
+
97
+
98
+ # --- Response enforcement ---
99
+ def enforce_response(strategy: str, text: str) -> str:
100
+ """Post-process model output to strongly encourage the chosen format."""
101
+ t = (text or "").strip()
102
+
103
+ if strategy == "structured_bullets":
104
+ parts = re.split(r"[\n]+", t)
105
+ if len(parts) <= 2 and len(t) > 160:
106
+ parts = re.split(r"(?<=[.!])\s+", t)
107
+ cleaned = []
108
+ for p in parts:
109
+ p = p.strip().lstrip("-•* ").strip()
110
+ if not p:
111
+ continue
112
+ if "?" in p:
113
+ continue
114
+ cleaned.append(p)
115
+ cleaned = cleaned[:5]
116
+ if len(cleaned) < 3:
117
+ cleaned = cleaned or [t.replace("?", "").strip()]
118
+ return "\n".join(["- " + c for c in cleaned[:5]])
119
+
120
+ if strategy == "step_by_step":
121
+ lines = [ln.strip() for ln in re.split(r"[\n]+", t) if ln.strip()]
122
+ items = []
123
+ for ln in lines:
124
+ ln = re.sub(r"^([\-*•]|\d+[.)])\s*", "", ln).strip()
125
+ if ln:
126
+ items.append(ln)
127
+ items = items[:6] or [t]
128
+ return "\n".join([f"{i+1}. {it}" for i, it in enumerate(items[:6])])
129
+
130
+ if strategy == "concise_direct":
131
+ sents = re.split(r"(?<=[.!?])\s+", t)
132
+ return " ".join(sents[:3]).strip()
133
+
134
+ if strategy == "socratic_questions":
135
+ qs = re.findall(r"[^\n?]*\?", t)
136
+ if qs:
137
+ qs = [q.strip() for q in qs if q.strip()][:2]
138
+ return "Got it.\n" + "\n".join(["- " + q for q in qs])
139
+ return "Got it.\n- What outcome do you want?\n- Any constraints or example input/output?"
140
+
141
+ if strategy == "comparison_table":
142
+ try:
143
+ obj = json.loads(t)
144
+ if isinstance(obj, dict) and isinstance(obj.get("columns"), list) and isinstance(obj.get("rows"), list):
145
+ return json.dumps(obj)
146
+ except Exception:
147
+ pass
148
+
149
+ lines = [ln.rstrip() for ln in t.splitlines() if ln.strip()]
150
+ table_lines = [ln for ln in lines if "|" in ln]
151
+ if len(table_lines) >= 2:
152
+ if not any(re.match(r"^\|?\s*[:-]{2,}", ln) for ln in table_lines):
153
+ header = table_lines[0]
154
+ cols = [c.strip() for c in header.strip("|").split("|")]
155
+ sep = "|" + "|".join(["---"] * len(cols)) + "|"
156
+ return "\n".join([header, sep] + table_lines[1:6])
157
+ return "\n".join(table_lines[:8])
158
+
159
+ fallback = {
160
+ "columns": ["Option", "Pros", "Cons", "Best for"],
161
+ "rows": [
162
+ ["A", "", "", ""],
163
+ ["B", "", "", ""],
164
+ ],
165
+ }
166
+ return json.dumps(fallback)
167
+
168
+ if strategy == "visualization":
169
+ try:
170
+ obj = json.loads(t)
171
+ if (
172
+ isinstance(obj, dict)
173
+ and isinstance(obj.get("type"), str)
174
+ and isinstance(obj.get("labels"), list)
175
+ and isinstance(obj.get("values"), list)
176
+ ):
177
+ return json.dumps(obj)
178
+ except Exception:
179
+ pass
180
+
181
+ m = re.search(r"```[\s\S]*?```", t)
182
+ if m:
183
+ return m.group(0)
184
+
185
+ fallback = {
186
+ "type": "bar",
187
+ "title": "Visualization",
188
+ "labels": ["A", "B"],
189
+ "values": [1, 1],
190
+ "x_label": "Category",
191
+ "y_label": "Value",
192
+ }
193
+ return json.dumps(fallback)
194
+
195
+ return t
anupa/backend/widget_prompt.py ADDED
@@ -0,0 +1,685 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ widget_prompt_gpt4.py — GPT-4 optimized widget generation prompt.
3
+
4
+ GPT-4 is large enough to infer most patterns from training.
5
+ This prompt is shorter and higher-level than the earlier local-model version —
6
+ it sets intent and constraints, not step-by-step instructions.
7
+
8
+ Usage:
9
+ from src.widget_prompt_gpt4 import build_widget_prompt, extract_widget_html, estimate_widget_height
10
+ from openai import OpenAI
11
+
12
+ client = OpenAI(api_key="your-key")
13
+
14
+ prompt = build_widget_prompt(
15
+ strategy_id=strategy_id,
16
+ user_message=user_message,
17
+ assistant_response=response,
18
+ event=event,
19
+ )
20
+
21
+ completion = client.chat.completions.create(
22
+ model="gpt-4o",
23
+ messages=[
24
+ {"role": "system", "content": SYSTEM_PROMPT},
25
+ {"role": "user", "content": prompt},
26
+ ],
27
+ temperature=0.3,
28
+ max_tokens=4096,
29
+ )
30
+
31
+ html = extract_widget_html(completion.choices[0].message.content)
32
+ """
33
+
34
+ from __future__ import annotations
35
+ import re
36
+
37
+
38
+ # ══════════════════════════════════════════════════════════════════════════════
39
+ # Design system — injected into every widget at post-processing time
40
+ # GPT-4 uses CSS variable names in its output; this supplies the values
41
+ # ══════════════════════════════════════════════════════════════════════════════
42
+
43
+ _DESIGN_SYSTEM_CSS = """<style id="__ds__">
44
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
45
+ :root{
46
+ --bg:#ffffff;--bg2:#f7f8fa;--bg3:#eef0f4;
47
+ --text:#111318;--text2:#5a5f72;--text3:#9098b0;
48
+ --border:rgba(0,0,0,0.08);--border2:rgba(0,0,0,0.15);
49
+ --accent:#378ADD;--accent-bg:rgba(55,138,221,0.09);--accent-b:rgba(55,138,221,0.35);
50
+ --success:#1D9E75;--success-bg:rgba(29,158,117,0.09);
51
+ --warn:#BA7517;--warn-bg:rgba(186,117,23,0.09);
52
+ --danger:#E24B4A;--danger-bg:rgba(226,75,74,0.09);
53
+ --radius:10px;--radius-sm:6px;--radius-pill:99px;
54
+ }
55
+ @media(prefers-color-scheme:dark){:root{
56
+ --bg:#13151c;--bg2:#1a1d27;--bg3:#20232f;
57
+ --text:#e8eaf4;--text2:#8d93aa;--text3:#555b72;
58
+ --border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.14);
59
+ --accent:#5ba4f5;--accent-bg:rgba(91,164,245,0.10);--accent-b:rgba(91,164,245,0.35);
60
+ }}
61
+ html,body{margin:0!important;padding:12px 14px!important;
62
+ background:transparent!important;color:var(--text);
63
+ font-family:"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6}
64
+ @keyframes __fu{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
65
+ .widget-root{animation:__fu .22s ease-out}
66
+ .card{background:var(--bg2);border:0.5px solid var(--border);border-radius:var(--radius);padding:14px 16px;margin-bottom:12px;transition:border-color .15s}
67
+ .card:hover{border-color:var(--border2)}
68
+ .card-title{font-size:11px;font-weight:500;color:var(--text2);text-transform:uppercase;letter-spacing:.05em;margin-bottom:12px}
69
+ .raised{background:var(--bg);border:0.5px solid var(--border);border-radius:var(--radius);padding:14px;transition:border-color .15s,transform .1s;cursor:pointer}
70
+ .raised:hover{border-color:var(--accent-b);transform:translateY(-1px)}
71
+ .raised:active{transform:translateY(0) scale(.992)}
72
+ .raised.highlight{border:1.5px solid var(--accent-b)}
73
+ .tabs{display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap}
74
+ .tab{padding:5px 13px;border-radius:var(--radius-sm);border:0.5px solid var(--border2);background:transparent;color:var(--text2);cursor:pointer;font-size:12px;transition:all .15s}
75
+ .tab:hover{background:var(--bg3);color:var(--text)}
76
+ .tab:active{transform:scale(.985)}
77
+ .tab.active{background:var(--bg3);color:var(--text);border-color:var(--accent)}
78
+ .panel{display:none}.panel.active{display:block}
79
+ table{width:100%;border-collapse:collapse;font-size:13px}
80
+ th{background:var(--bg3);color:var(--text2);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.04em;padding:8px 11px;text-align:left;border-bottom:0.5px solid var(--border2)}
81
+ td{padding:8px 11px;border-bottom:0.5px solid var(--border);color:var(--text);vertical-align:middle}
82
+ tr:last-child td{border-bottom:none}
83
+ tr.clickable:hover td{background:var(--accent-bg);cursor:pointer}
84
+ .search{width:100%;padding:8px 12px;border-radius:var(--radius-sm);border:0.5px solid var(--border2);background:var(--bg);color:var(--text);font-size:13px;outline:none;box-sizing:border-box;margin-bottom:10px;transition:border-color .15s}
85
+ .search:focus{border-color:var(--accent)}
86
+ .pills{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
87
+ .pill{padding:4px 12px;border-radius:var(--radius-pill);border:0.5px solid var(--border2);background:transparent;color:var(--text2);cursor:pointer;font-size:12px;transition:all .15s}
88
+ .pill:hover{background:var(--bg3)}.pill.active{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-b)}
89
+ .pill:active{transform:scale(.985)}
90
+ .ctrl-row{display:flex;align-items:center;gap:10px;margin-bottom:10px}
91
+ .ctrl-lbl{font-size:12px;color:var(--text2);width:115px;flex-shrink:0}
92
+ .ctrl-val{font-size:12px;font-weight:500;color:var(--text);min-width:54px;text-align:right}
93
+ .btn-group{display:flex;gap:6px;flex-wrap:wrap}
94
+ .btn{padding:6px 13px;border-radius:var(--radius-sm);border:0.5px solid var(--border2);background:transparent;color:var(--text2);cursor:pointer;font-size:12px;transition:all .15s}
95
+ .btn:hover{background:var(--bg3);color:var(--text)}.btn.active{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-b)}
96
+ .btn:active{transform:scale(.985)}
97
+ .ask-btn{display:inline-flex;align-items:center;gap:5px;margin-top:12px;padding:7px 14px;border-radius:var(--radius-sm);border:0.5px solid var(--border2);background:transparent;color:var(--text2);font-size:12px;cursor:pointer;transition:all .15s}
98
+ .ask-btn:hover{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-b)}
99
+ .ask-btn:active{transform:scale(.985)}
100
+ .badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}
101
+ .b-blue{background:var(--accent-bg);color:var(--accent)}.b-green{background:var(--success-bg);color:var(--success)}
102
+ .b-amber{background:var(--warn-bg);color:var(--warn)}.b-red{background:var(--danger-bg);color:var(--danger)}.b-gray{background:var(--bg3);color:var(--text2)}
103
+ .metric-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin-bottom:14px}
104
+ .metric{background:var(--bg3);border-radius:var(--radius-sm);padding:12px}
105
+ .metric-lbl{font-size:11px;color:var(--text2);margin-bottom:4px}.metric-val{font-size:22px;font-weight:500}
106
+ .progress-wrap{background:var(--bg3);border-radius:var(--radius-pill);height:8px;overflow:hidden;margin-top:4px}
107
+ .progress-bar{height:100%;border-radius:var(--radius-pill);background:var(--accent);transition:width .4s ease-out}
108
+ .result-box{background:var(--bg2);border-radius:var(--radius-sm);padding:14px;margin-top:12px}
109
+ .result-lbl{font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px}
110
+ .result-val{font-size:26px;font-weight:500}.result-sub{font-size:12px;color:var(--text2);margin-top:3px}
111
+ .step-row{display:flex;gap:12px;align-items:flex-start;padding:10px 8px;border-radius:var(--radius-sm);border-bottom:0.5px solid var(--border);cursor:pointer;transition:background .1s}
112
+ .step-row:last-child{border-bottom:none}.step-row:hover{background:var(--accent-bg)}
113
+ .step-num{width:28px;height:28px;border-radius:50%;background:var(--accent-bg);color:var(--accent);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex-shrink:0}
114
+ .step-title{font-size:14px;font-weight:500}.step-desc{font-size:12px;color:var(--text2);margin-top:2px;line-height:1.5}
115
+ .count-lbl{font-size:12px;color:var(--text2);margin-bottom:8px}
116
+ .empty{font-size:13px;color:var(--text2);padding:12px 0;text-align:center}
117
+ </style>"""
118
+
119
+ _SEND_PROMPT_BRIDGE = """<script>
120
+ if(!window.sendPrompt)window.sendPrompt=function(t){
121
+ window.parent.postMessage({type:"streamlit:setComponentValue",value:t},"*");
122
+ };
123
+ function __postWidgetHeight(){
124
+ try{
125
+ const b=document.body, d=document.documentElement;
126
+ const h=Math.max(
127
+ b ? b.scrollHeight : 0,
128
+ d ? d.scrollHeight : 0,
129
+ b ? b.offsetHeight : 0,
130
+ d ? d.offsetHeight : 0
131
+ );
132
+ window.parent.postMessage({type:"widget:height",value:(h+20)},"*");
133
+ }catch(_e){}
134
+ }
135
+ if(!window.__widgetHeightBound){
136
+ window.__widgetHeightBound=true;
137
+ window.addEventListener("load",function(){
138
+ __postWidgetHeight();
139
+ setTimeout(__postWidgetHeight,120);
140
+ setTimeout(__postWidgetHeight,420);
141
+ });
142
+ if(typeof ResizeObserver!=="undefined"){
143
+ try{
144
+ const ro=new ResizeObserver(function(){__postWidgetHeight();});
145
+ ro.observe(document.body || document.documentElement);
146
+ }catch(_e){}
147
+ }
148
+ }
149
+ </script>"""
150
+
151
+
152
+ # ══════════════════════════════════════════════════════════════════════════════
153
+ # System prompt — short and high-level for GPT-4
154
+ # GPT-4 already knows HTML/JS/CSS deeply — this sets intent + constraints only
155
+ # ══════════════════════════════════════════════════════════════════════════════
156
+
157
+ SYSTEM_PROMPT = """You are a Visualizer embedded in a chat assistant.
158
+
159
+ When given a user question and assistant response, you generate a single
160
+ self-contained interactive HTML widget — like the widgets in Claude.ai.
161
+
162
+ ## Output rules
163
+ - Return ONLY raw HTML. No markdown fences. No explanation.
164
+ - Inline all CSS in <style> and JS in <script>.
165
+ - No frameworks. Plain HTML + CSS + JS only.
166
+ - Chart.js allowed: https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js
167
+ - Wrap everything in <div class="widget-root">
168
+ - No position:fixed. No hardcoded hex colors — CSS variables only.
169
+ - Always define: function sendPrompt(t){window.parent.postMessage({type:"streamlit:setComponentValue",value:t},"*");}
170
+ - Always call your main render/calc function on page load.
171
+
172
+ ## Design system
173
+ A CSS design system with these variables is already injected:
174
+ --bg --bg2 --bg3 (backgrounds)
175
+ --text --text2 --text3 (text)
176
+ --border --border2 (borders)
177
+ --accent --accent-bg --accent-b (blue)
178
+ --success --success-bg (green)
179
+ --warn --warn-bg (amber)
180
+ --danger --danger-bg (red)
181
+ --radius --radius-sm --radius-pill
182
+
183
+ Pre-built classes available: .card .raised .card-title .tabs .tab .panel
184
+ table th td tr.clickable .search .pills .pill .ctrl-row .ctrl-lbl .ctrl-val
185
+ .btn-group .btn .ask-btn .badge .b-blue .b-green .b-amber .b-red .b-gray
186
+ .metric-grid .metric .metric-lbl .metric-val .progress-wrap .progress-bar
187
+ .result-box .result-lbl .result-val .result-sub .step-row .step-num
188
+ .step-title .step-desc .count-lbl .empty
189
+
190
+ For Chart.js colors (canvas can't use CSS vars):
191
+ const dark=matchMedia('(prefers-color-scheme:dark)').matches;
192
+ const tc=dark?'#8d93aa':'#5a5f72';
193
+ const gc=dark?'rgba(255,255,255,0.06)':'rgba(0,0,0,0.06)';
194
+
195
+ ## Visual quality bar
196
+ - Aim for an enterprise dashboard look-and-feel: clean, minimal, high information density.
197
+ - Prefer clear structure: summary row → controls → main visualization → details / next steps.
198
+ - Use spacing and typography hierarchy instead of heavy borders.
199
+ - Group related controls and metrics in cards so the widget feels like a cohesive mini app.
200
+
201
+ ## What to build — pick controls that fit the content
202
+
203
+ | Content type | Controls to use |
204
+ |---|---|
205
+ | 2+ options to compare | Comparison cards (.raised grid) or table, highlight best |
206
+ | 5+ list items | Search input + count label + clickable rows |
207
+ | Items with categories | Filter pills |
208
+ | Adjustable numbers | Range sliders (.ctrl-row) + live calc() + .ask-btn |
209
+ | Percentages/allocations | Progress bars (.progress-wrap) |
210
+ | Time series / trends | Chart.js line chart + period buttons |
211
+ | Rankings / comparisons | Chart.js bar chart |
212
+ | Step-by-step process | .step-row list with Prev/Next or all visible |
213
+ | Key stats / numbers | .metric-grid cards |
214
+ | 2+ sections / topics | Tabs (.tabs .tab .panel) |
215
+ | Calculator output | .result-box + .ask-btn with values baked in |
216
+
217
+ ## Interaction rules
218
+ - Sliders → local calc() only. Never sendPrompt on drag.
219
+ - Slider initial values MUST match assistant_response exactly.
220
+ - If response says P=1000, r=5%, years=10, use those exact defaults (no generic defaults).
221
+ - Cards, rows, results → sendPrompt('specific question about [exact item]')
222
+ - Always add .ask-btn below calculator output
223
+ - Every clickable element needs a hover state
224
+ - sendPrompt text must be specific — never generic "tell me more"
225
+
226
+ ## What not to do
227
+ - No hardcoded colors
228
+ - No sendPrompt on slider drag
229
+ - No empty output on first render — always call calc() on load
230
+ - No placeholder or lorem ipsum data — use real content only
231
+ - No position:fixed
232
+ - No external fonts or icon libraries"""
233
+
234
+
235
+ # ══════════════════════════════════════════════════════════════════════════════
236
+ # Content signal detector
237
+ # ══════════════════════════════════════════════════════════════════════════════
238
+
239
+ def _detect_signals(event: str, user_message: str, response: str) -> str:
240
+ """Detect visual signals in the response and return hints for GPT-4."""
241
+ hints: list[str] = []
242
+ text = response.lower()
243
+ lines = response.split('\n')
244
+
245
+ list_items = sum(1 for l in lines if l.strip().startswith(('-','•','*','→'))
246
+ or (len(l.strip())>2 and l.strip()[0].isdigit() and l.strip()[1] in '.):'))
247
+ num_count = len(re.findall(r'\b\d+\.?\d*\b', response))
248
+ pct_count = len(re.findall(r'\d+\.?\d*\s*%', response))
249
+ word_count = len(response.split())
250
+ section_cnt = len(re.findall(r'\n#{1,3}\s', response))
251
+
252
+ if event == 'decision' or any(w in text for w in ['vs','versus','compare','pros','cons','difference']):
253
+ hints.append("COMPARISON: comparison cards or table — highlight recommended option")
254
+
255
+ if list_items > 5:
256
+ hints.append(f"SEARCH: {list_items} items — add search input and count label")
257
+ elif list_items > 1:
258
+ hints.append(f"LIST: {list_items} items — interactive clickable list")
259
+
260
+ if num_count >= 4:
261
+ calc_kw = ['invest','compound','interest','return','growth','project',
262
+ 'forecast','calculate','savings','mortgage','loan','retire']
263
+ if any(w in text for w in calc_kw):
264
+ hints.append("CALCULATOR: range sliders + live calc() + Chart.js line chart + ask button")
265
+ else:
266
+ hints.append(f"METRICS: {num_count} numbers — metric cards")
267
+
268
+ if pct_count >= 2:
269
+ hints.append(f"PROGRESS: {pct_count} percentages — animated progress bars")
270
+
271
+ if sum(1 for w in ['step','first','second','then','next','finally','how to'] if w in text) >= 2:
272
+ hints.append("STEPS: step list with click-to-ask on each step")
273
+
274
+ time_kw = ['year','month','quarter','trend','growth','over time','history','forecast','annual']
275
+ if sum(1 for w in time_kw if w in text) >= 2 and num_count >= 3:
276
+ hints.append("CHART: time/trend data — Chart.js line chart with period buttons")
277
+
278
+ if section_cnt >= 2 or word_count > 200:
279
+ hints.append("TABS: 2+ sections detected — organize with tabs")
280
+
281
+ if list_items > 3 and any(w in text for w in ['type','category','kind','group']):
282
+ hints.append("FILTERS: categorical items — filter pills")
283
+
284
+ # FAQ-like content => accordion for progressive disclosure.
285
+ faq_markers = ['faq', 'frequently asked', 'q:', 'a:', 'question', 'answer']
286
+ if any(w in text for w in faq_markers):
287
+ hints.append("FAQ: accordion sections with concise expandable answers")
288
+
289
+ # Elimination flow / shortlist decisions => elimination matrix.
290
+ elim_markers = ['eliminate', 'shortlist', 'screen', 'must-have', 'nice to have', 'criteria']
291
+ if any(w in text for w in elim_markers):
292
+ hints.append("ELIMINATION_MATRIX: criteria-based keep/drop table with rationale")
293
+
294
+ # Correlation/distribution style numeric pairs => bubble/scatter.
295
+ scatter_markers = ['correlation', 'relationship', 'risk vs return', 'x-axis', 'y-axis', 'distribution']
296
+ if any(w in text for w in scatter_markers):
297
+ hints.append("SCATTER: bubble/scatter chart for pairwise numeric relationship")
298
+
299
+ return '\n'.join(f"• {h}" for h in hints) if hints else "• SIMPLE: clean card with clickable content"
300
+
301
+
302
+ # ══════════════════════════════════════════════════════════════════════════════
303
+ # Public API
304
+ # ══════════════════════════════════════════════════════════════════════════════
305
+
306
+
307
+ def requires_local_interaction(user_message: str, assistant_response: str, event: str = "") -> bool:
308
+ """Detect when widget should support local value tweaking (sliders/inputs + calc)."""
309
+ text = f"{user_message}\n{assistant_response}".lower()
310
+ strong_intent_words = [
311
+ "slider",
312
+ "sliders",
313
+ "tweak",
314
+ "adjust",
315
+ "change values",
316
+ "interactive calculator",
317
+ "calculator",
318
+ "projection",
319
+ "project",
320
+ "what if",
321
+ ]
322
+ finance_tweak_words = [
323
+ "slider",
324
+ "adjust",
325
+ "tweak",
326
+ "change",
327
+ "what if",
328
+ "calculate",
329
+ "calculator",
330
+ "projection",
331
+ "project",
332
+ "forecast",
333
+ "invest",
334
+ "interest",
335
+ "returns",
336
+ "years",
337
+ "rate",
338
+ "monthly",
339
+ "sip",
340
+ "emi",
341
+ "loan",
342
+ "mortgage",
343
+ ]
344
+ number_count = len(re.findall(r"\b\d+\.?\d*\b", text))
345
+ if event.lower() in {"calculation", "planner"}:
346
+ return True
347
+ if any(w in text for w in strong_intent_words):
348
+ return True
349
+ if any(w in text for w in finance_tweak_words) and number_count >= 1:
350
+ return True
351
+ return False
352
+
353
+
354
+ def has_local_interaction_controls(html: str) -> bool:
355
+ """Check that generated widget has true local interaction controls."""
356
+ if not html:
357
+ return False
358
+ h = html.lower()
359
+ has_input = (
360
+ 'type="range"' in h
361
+ or "type='range'" in h
362
+ or 'type="number"' in h
363
+ or "type='number'" in h
364
+ )
365
+ has_calc = "function calc" in h or "oninput=\"calc(" in h or "oninput='calc(" in h
366
+ return has_input and has_calc
367
+
368
+
369
+ INTENT_WIDGET_MAP = {
370
+ # Event-style labels
371
+ "decision": "comparison_cards + horizontal_bar + tabs",
372
+ "information": "data_table + search + metric_cards",
373
+ "confusion": "step_navigator + info_card",
374
+ "summary": "metric_cards + bullet_list + tabs",
375
+ "follow_up": "honor_previous_widget_type",
376
+ # Strategy-style labels (used by this app currently)
377
+ "comparison_table": "comparison_cards + data_table + filter_pills",
378
+ "visualization": "line_or_bar_chart + metric_cards",
379
+ "step_by_step": "step_navigator + insight_card",
380
+ "structured_bullets": "insight_card + drill_down_cards",
381
+ "narrative_prose": "tabs + insight_card + metric_cards",
382
+ "concise_direct": "result_box + ask_button",
383
+ "socratic_questions": "accordion_faq + ask_button",
384
+ }
385
+
386
+
387
+ def _intent_widget_hint(event: str, user_message: str) -> str:
388
+ """Return an intent-first widget hint before content signal hints."""
389
+ e = (event or "").strip().lower()
390
+ if e in INTENT_WIDGET_MAP:
391
+ return INTENT_WIDGET_MAP[e]
392
+
393
+ q = (user_message or "").lower()
394
+ if any(w in q for w in ["compare", "vs", "versus", "pros", "cons"]):
395
+ return "comparison_cards + horizontal_bar + tabs"
396
+ if any(w in q for w in ["calculate", "projection", "what if", "compound", "interest"]):
397
+ return "range_slider + metric_cards + line_chart + ask_button"
398
+ if any(w in q for w in ["list", "top", "show all", "find"]):
399
+ return "search + filter_pills + data_table + ask_button"
400
+ if any(w in q for w in ["how to", "steps", "guide"]):
401
+ return "step_navigator + info_card"
402
+ return "info_card + metric_cards"
403
+
404
+
405
+ def _extract_calc_defaults(text: str) -> dict:
406
+ """Extract calculator defaults from assistant response text."""
407
+ src = text or ""
408
+ out = {
409
+ "principal": None,
410
+ "rate": None,
411
+ "years": None,
412
+ "n": None,
413
+ "final": None,
414
+ }
415
+
416
+ p = re.search(r"(?:principal|p)\s*[:=]\s*\$?\s*([0-9][0-9,]*(?:\.\d+)?)", src, re.IGNORECASE)
417
+ if p:
418
+ out["principal"] = float(p.group(1).replace(",", ""))
419
+
420
+ r = re.search(r"(?:rate|r)\s*[:=]\s*([0-9]+(?:\.\d+)?)\s*%?", src, re.IGNORECASE)
421
+ if r:
422
+ out["rate"] = float(r.group(1))
423
+
424
+ y = re.search(r"(?:years?|t)\s*[:=]\s*([0-9]{1,3})\b", src, re.IGNORECASE)
425
+ if y:
426
+ out["years"] = int(y.group(1))
427
+
428
+ n = re.search(r"\bn\s*[:=]\s*([0-9]{1,3})\b", src, re.IGNORECASE)
429
+ if n:
430
+ out["n"] = int(n.group(1))
431
+
432
+ a = re.search(r"(?:final|amount|a|fv)\s*[≈~=:\s]+\$?\s*([0-9][0-9,]*(?:\.\d+)?)", src, re.IGNORECASE)
433
+ if a:
434
+ out["final"] = float(a.group(1).replace(",", ""))
435
+
436
+ # Fallbacks from generic values in text if explicit labels are missing.
437
+ if out["principal"] is None:
438
+ m = re.search(r"\$([0-9][0-9,]*(?:\.\d+)?)", src)
439
+ if m:
440
+ out["principal"] = float(m.group(1).replace(",", ""))
441
+ if out["rate"] is None:
442
+ m = re.search(r"([0-9]+(?:\.\d+)?)\s*%", src)
443
+ if m:
444
+ out["rate"] = float(m.group(1))
445
+ if out["years"] is None:
446
+ m = re.search(r"([0-9]{1,2})\s*(?:years?|yrs?)", src, re.IGNORECASE)
447
+ if m:
448
+ out["years"] = int(m.group(1))
449
+
450
+ return out
451
+
452
+ def build_widget_prompt(
453
+ strategy_id: str,
454
+ user_message: str,
455
+ assistant_response: str,
456
+ event: str = "",
457
+ extra_context: str = "",
458
+ ) -> str:
459
+ """
460
+ Build the user-turn prompt for GPT-4 widget generation.
461
+ Pass SYSTEM_PROMPT as the system message separately.
462
+
463
+ This version deliberately does NOT use keyword- or regex-based
464
+ heuristics to choose controls. The model is responsible for
465
+ reading the full assistant_response and user_message and deciding
466
+ which interactive UI (tabs, charts, sliders, filters, tables,
467
+ etc.) best serves the content.
468
+
469
+ Args:
470
+ strategy_id: primitive id (e.g. 'comparison_table')
471
+ user_message: original user question
472
+ assistant_response: text response from engine
473
+ event: classified event type
474
+ extra_context: optional hints from primitive_widget_map.py
475
+ """
476
+ defaults = _extract_calc_defaults(assistant_response)
477
+
478
+ extra = f"\nPrimitive instructions:\n{extra_context}\n" if extra_context else ""
479
+ grounding = ""
480
+ if any(v is not None for v in defaults.values()):
481
+ parts = []
482
+ if defaults["principal"] is not None:
483
+ parts.append(f"Principal = ${int(defaults['principal']):,}")
484
+ if defaults["rate"] is not None:
485
+ parts.append(f"Rate = {defaults['rate']}%")
486
+ if defaults["years"] is not None:
487
+ parts.append(f"Years = {defaults['years']}")
488
+ if defaults["n"] is not None:
489
+ parts.append(f"n = {defaults['n']}")
490
+ if defaults["final"] is not None:
491
+ parts.append(f"Final answer = ${defaults['final']:,.2f}")
492
+ grounding = (
493
+ "PRE-FILL THESE EXACT VALUES into slider defaults and initial state:\n"
494
+ + "\n".join(f"- {p}" for p in parts)
495
+ + "\nDo not invent alternative defaults."
496
+ )
497
+
498
+ guidance = (
499
+ "Decide the widget layout and controls by understanding the meaning of the "
500
+ "assistant_response and user_message. Do NOT rely on fixed keyword triggers. "
501
+ "If the explanation compares options, use comparison-style UI; if it describes "
502
+ "time or trends, use charts; if it walks through a process, use step-style UI; "
503
+ "if it exposes tweakable numeric parameters, use sliders/inputs with live calc(). "
504
+ "Always favor interactive controls that let the user explore the specific numbers, "
505
+ "entities, and scenarios mentioned in the assistant_response.\n"
506
+ )
507
+
508
+ return (
509
+ f"Event: {event or 'unknown'} | Strategy: {strategy_id}\n"
510
+ f"{extra}\n"
511
+ f"{grounding}\n\n"
512
+ f"{guidance}\n"
513
+ f"User asked:\n{user_message}\n\n"
514
+ f"Assistant response to visualize:\n{assistant_response}\n\n"
515
+ f"Generate the widget HTML now."
516
+ )
517
+
518
+
519
+ def inject_design_system(html: str) -> str:
520
+ """Post-process: inject design system CSS + sendPrompt bridge."""
521
+ if not html:
522
+ return html
523
+
524
+ if '__ds__' not in html:
525
+ if re.search(r'<head[^>]*>', html, re.IGNORECASE):
526
+ html = re.sub(r'(<head[^>]*>)', r'\1\n' + _DESIGN_SYSTEM_CSS,
527
+ html, count=1, flags=re.IGNORECASE)
528
+ else:
529
+ html = _DESIGN_SYSTEM_CSS + html
530
+
531
+ if 'streamlit:setComponentValue' not in html:
532
+ if re.search(r'</body>', html, re.IGNORECASE):
533
+ html = re.sub(r'(</body>)', _SEND_PROMPT_BRIDGE + r'\n\1',
534
+ html, count=1, flags=re.IGNORECASE)
535
+ else:
536
+ html += _SEND_PROMPT_BRIDGE
537
+
538
+ # Force transparent body
539
+ html = re.sub(
540
+ r'(body\s*\{[^}]*?)background(?:-color)?\s*:\s*(?!transparent)[^;]+;',
541
+ r'\1background:transparent!important;',
542
+ html, flags=re.IGNORECASE
543
+ )
544
+
545
+ # Remove position:fixed
546
+ html = re.sub(r'position\s*:\s*fixed', 'position:absolute', html, flags=re.IGNORECASE)
547
+
548
+ # Enforce .widget-root wrapper so mount animation always fires.
549
+ if 'class="widget-root"' not in html and "class='widget-root'" not in html:
550
+ if re.search(r'<body[^>]*>', html, re.IGNORECASE):
551
+ html = re.sub(r'(<body[^>]*>)', r'\1<div class="widget-root">', html, count=1, flags=re.IGNORECASE)
552
+ if re.search(r'</body>', html, re.IGNORECASE):
553
+ html = re.sub(r'(</body>)', r'</div>\n\1', html, count=1, flags=re.IGNORECASE)
554
+ else:
555
+ html += "</div>"
556
+ else:
557
+ html = f'<div class="widget-root">{html}</div>'
558
+
559
+ # Refine generated UI borders to feel lighter in chat surfaces.
560
+ html = re.sub(r'border\s*:\s*1px\s*solid', 'border: 0.5px solid', html, flags=re.IGNORECASE)
561
+ html = re.sub(r'border-width\s*:\s*1px\b', 'border-width: 0.5px', html, flags=re.IGNORECASE)
562
+
563
+ return html
564
+
565
+
566
+ def extract_widget_html(raw: str) -> str | None:
567
+ """Extract and clean HTML from GPT-4 output."""
568
+ if not raw:
569
+ return None
570
+
571
+ html = raw.strip()
572
+
573
+ # Strip markdown fences (GPT-4 sometimes wraps in ```html)
574
+ if '```' in html:
575
+ fence = re.search(r'```html\s*(.*?)```', html, re.DOTALL | re.IGNORECASE)
576
+ html = fence.group(1).strip() if fence else re.sub(r'```\w*', '', html).strip()
577
+
578
+ if '<' not in html or '>' not in html:
579
+ return None
580
+
581
+ if '<html' not in html.lower():
582
+ html = f"<html><head></head><body>{html}</body></html>"
583
+
584
+ html = re.sub(r'<!DOCTYPE[^>]*>', '', html, flags=re.IGNORECASE).strip()
585
+ html = inject_design_system(html)
586
+
587
+ return html
588
+
589
+
590
+ def estimate_widget_height(html: str) -> int:
591
+ """Estimate iframe height from widget content."""
592
+ if not html:
593
+ return 300
594
+
595
+ h = html.lower()
596
+ height = 120
597
+ height += h.count('<canvas') * 240
598
+ height += h.count('<table') * 160
599
+ height += min(h.count('<tr'), 15) * 36
600
+ height += (h.count('class="card') + h.count("class='card")) * 80
601
+ height += (h.count('class="raised') + h.count("class='raised")) * 100
602
+ height += min(h.count('<li'), 12) * 30
603
+ height += (h.count('type="range') + h.count("type='range")) * 54
604
+ height += h.count('<select') * 50
605
+ height += h.count('step-row') * 56
606
+ height += h.count('metric-grid') * 100
607
+ height += h.count('result-box') * 90
608
+ height += (h.count('class="tab') + h.count("class='tab")) * 10
609
+
610
+ return max(280, min(height, 920))
611
+
612
+
613
+ def build_local_calc_fallback(user_message: str, assistant_response: str) -> str:
614
+ """Deterministic local calculator widget used when LLM output is static."""
615
+ defaults = _extract_calc_defaults(assistant_response)
616
+ nums = [float(x) for x in re.findall(r"\b\d+\.?\d*\b", f"{user_message} {assistant_response}")[:3]]
617
+ principal = int(defaults["principal"]) if defaults["principal"] is not None else (int(nums[0]) if len(nums) > 0 else 10000)
618
+ rate = float(defaults["rate"]) if defaults["rate"] is not None else (float(nums[1]) if len(nums) > 1 else 8.0)
619
+ years = int(defaults["years"]) if defaults["years"] is not None else (int(nums[2]) if len(nums) > 2 else 10)
620
+ n = int(defaults["n"]) if defaults["n"] is not None else 1
621
+ target = float(defaults["final"]) if defaults["final"] is not None else None
622
+
623
+ principal = max(1000, min(principal, 2000000))
624
+ rate = max(1.0, min(rate, 25.0))
625
+ years = max(1, min(years, 40))
626
+ n = max(1, min(n, 365))
627
+
628
+ html = f"""
629
+ <html><head></head><body>
630
+ <div class="widget-root card">
631
+ <div class="card-title">Interactive Projection</div>
632
+ <div class="ctrl-row">
633
+ <div class="ctrl-lbl">Principal</div>
634
+ <input id="p" type="range" min="1000" max="2000000" step="1000" value="{principal}" oninput="calc()" style="flex:1" />
635
+ <div class="ctrl-val" id="pv"></div>
636
+ </div>
637
+ <div class="ctrl-row">
638
+ <div class="ctrl-lbl">Rate (%)</div>
639
+ <input id="r" type="range" min="1" max="25" step="0.1" value="{rate}" oninput="calc()" style="flex:1" />
640
+ <div class="ctrl-val" id="rv"></div>
641
+ </div>
642
+ <div class="ctrl-row">
643
+ <div class="ctrl-lbl">Years</div>
644
+ <input id="y" type="range" min="1" max="40" step="1" value="{years}" oninput="calc()" style="flex:1" />
645
+ <div class="ctrl-val" id="yv"></div>
646
+ </div>
647
+ <div class="result-box">
648
+ <div class="result-lbl">Projected Value</div>
649
+ <div class="result-val" id="out">$0</div>
650
+ <div class="result-sub" id="gain"></div>
651
+ <div class="result-sub">Compounding frequency n = {n}</div>
652
+ {"<div class='result-sub'>Response target: $" + f"{target:,.2f}" + "</div>" if target is not None else ""}
653
+ </div>
654
+ <button class="ask-btn" onclick="askAbout()">Ask about this ↗</button>
655
+ </div>
656
+ <script>
657
+ function sendPrompt(t) {{
658
+ window.parent.postMessage({{type:"streamlit:setComponentValue",value:t}},"*");
659
+ }}
660
+ function fmt(n) {{
661
+ return '$' + Math.round(n).toLocaleString();
662
+ }}
663
+ function calc() {{
664
+ const P = +document.getElementById('p').value;
665
+ const R = +document.getElementById('r').value / 100;
666
+ const Y = +document.getElementById('y').value;
667
+ const n = {n};
668
+ const FV = P * Math.pow(1 + (R / n), n * Y);
669
+ document.getElementById('pv').textContent = fmt(P);
670
+ document.getElementById('rv').textContent = (R*100).toFixed(1) + '%';
671
+ document.getElementById('yv').textContent = Y + 'y';
672
+ document.getElementById('out').textContent = fmt(FV);
673
+ document.getElementById('gain').textContent = 'Estimated gain: ' + fmt(FV - P);
674
+ }}
675
+ function askAbout() {{
676
+ const P = document.getElementById('p').value;
677
+ const R = document.getElementById('r').value;
678
+ const Y = document.getElementById('y').value;
679
+ sendPrompt('Explain this projection for principal $' + P + ', rate ' + R + '%, years ' + Y);
680
+ }}
681
+ calc();
682
+ </script>
683
+ </body></html>
684
+ """.strip()
685
+ return inject_design_system(html)
anupa/index.html ADDED
@@ -0,0 +1,2175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Adaptive Presentation Engine</title>
7
+ <style>
8
+ /* ── PAGE SWITCHER ── */
9
+ .page { display: none; }
10
+ .page.active { display: contents; }
11
+ #fw-page { display: none; }
12
+ #fw-page.active { display: block; position: fixed; inset: 52px 0 0 0; overflow-y: auto; z-index: 50; }
13
+
14
+ .nav-tabs {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 2px;
18
+ margin-left: 18px;
19
+ }
20
+ .nav-tab {
21
+ font-family: 'Syne', sans-serif;
22
+ font-size: 11px;
23
+ font-weight: 700;
24
+ letter-spacing: .06em;
25
+ text-transform: uppercase;
26
+ padding: 5px 14px;
27
+ border-radius: 7px;
28
+ border: 1px solid transparent;
29
+ cursor: pointer;
30
+ background: transparent;
31
+ color: var(--dm);
32
+ transition: all .18s;
33
+ }
34
+ .nav-tab:hover { border-color: var(--bd); color: var(--tx); }
35
+ .nav-tab.active { background: var(--ac); color: #fff; border-color: var(--ac); }
36
+ .nav-tab.fw-tab.active { background: #1a3a5c; color: #5bc8ff; border-color: #1e4a6e; }
37
+
38
+ /* ── FUTURE WORK PAGE ── */
39
+ #fw-page {
40
+ background: #080d14;
41
+ color: #e2e8f0;
42
+ font-family: 'Inter', system-ui, sans-serif;
43
+ }
44
+
45
+ .fw-inner {
46
+ max-width: 680px;
47
+ margin: 0 auto;
48
+ padding: 52px 28px 80px;
49
+ }
50
+
51
+ .fw-eyebrow {
52
+ font-family: 'DM Mono', monospace;
53
+ font-size: 12px;
54
+ letter-spacing: .2em;
55
+ text-transform: uppercase;
56
+ color: #4a9eff;
57
+ margin-bottom: 14px;
58
+ }
59
+
60
+ .fw-title {
61
+ font-family: 'Syne', sans-serif;
62
+ font-size: 44px;
63
+ font-weight: 800;
64
+ color: #f0f6ff;
65
+ line-height: 1.1;
66
+ margin-bottom: 14px;
67
+ }
68
+
69
+ .fw-subtitle {
70
+ font-size: 16px;
71
+ color: #7a8fa6;
72
+ line-height: 1.7;
73
+ max-width: 580px;
74
+ margin-bottom: 36px;
75
+ }
76
+
77
+ /* Profile card */
78
+ .fw-profile {
79
+ background: #0d1724;
80
+ border: 1px solid #1a2d42;
81
+ border-radius: 16px;
82
+ padding: 22px 26px;
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: space-between;
86
+ margin-bottom: 24px;
87
+ flex-wrap: wrap;
88
+ gap: 16px;
89
+ }
90
+ .fw-profile-left { display: flex; flex-direction: column; gap: 4px; }
91
+ .fw-profile-name {
92
+ font-family: 'Syne', sans-serif;
93
+ font-size: 22px;
94
+ font-weight: 800;
95
+ color: #f0f6ff;
96
+ }
97
+ .fw-profile-sub { font-size: 13px; color: #4a5f72; font-family: 'DM Mono', monospace; }
98
+ .fw-profile-stats { display: flex; gap: 32px; }
99
+ .fw-stat { text-align: center; }
100
+ .fw-stat-val {
101
+ font-family: 'Syne', sans-serif;
102
+ font-size: 26px;
103
+ font-weight: 800;
104
+ line-height: 1;
105
+ }
106
+ .fw-stat-val.blue { color: #4a9eff; }
107
+ .fw-stat-val.green { color: #2ecc8c; }
108
+ .fw-stat-val.orange { color: #f5a623; }
109
+ .fw-stat-lbl { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: #4a5f72; margin-top: 4px; }
110
+
111
+ /* Portrait summary */
112
+ .fw-summary {
113
+ background: #0d1724;
114
+ border: 1px solid #1a2d42;
115
+ border-radius: 16px;
116
+ padding: 22px 26px;
117
+ margin-bottom: 28px;
118
+ }
119
+ .fw-summary-hdr {
120
+ font-family: 'DM Mono', monospace;
121
+ font-size: 10px;
122
+ letter-spacing: .18em;
123
+ text-transform: uppercase;
124
+ color: #3a6a9a;
125
+ margin-bottom: 12px;
126
+ }
127
+ .fw-summary-text {
128
+ font-size: 15px;
129
+ line-height: 1.85;
130
+ color: #8fa8c0;
131
+ }
132
+ .fw-summary-text .hi-blue { color: #4a9eff; font-weight: 600; }
133
+ .fw-summary-text .hi-green { color: #2ecc8c; font-weight: 600; }
134
+ .fw-summary-text .hi-orange { color: #f5a623; font-weight: 600; }
135
+
136
+ /* Facets section */
137
+ .fw-facets-hdr {
138
+ display: flex;
139
+ justify-content: space-between;
140
+ align-items: center;
141
+ margin-bottom: 14px;
142
+ }
143
+ .fw-facets-title {
144
+ font-family: 'DM Mono', monospace;
145
+ font-size: 9px;
146
+ letter-spacing: .18em;
147
+ text-transform: uppercase;
148
+ color: #3a6a9a;
149
+ }
150
+ .fw-facets-sort { font-size: 10px; color: #3a6a9a; }
151
+
152
+ /* Facet card */
153
+ .fw-facet {
154
+ background: #0d1724;
155
+ border: 1px solid #1a2d42;
156
+ border-radius: 16px;
157
+ margin-bottom: 12px;
158
+ overflow: hidden;
159
+ cursor: pointer;
160
+ transition: border-color .2s;
161
+ }
162
+ .fw-facet:hover { border-color: #254060; }
163
+ .fw-facet.expanded { border-color: currentColor; }
164
+ .fw-facet.blue-border { border-color: #1e4d80; }
165
+ .fw-facet.blue-border.expanded { border-color: #2a6bb5; }
166
+ .fw-facet.green-border { border-color: #1a4a30; }
167
+ .fw-facet.green-border.expanded { border-color: #2a8c5a; }
168
+ .fw-facet.orange-border { border-color: #4a3010; }
169
+ .fw-facet.orange-border.expanded { border-color: #c47d20; }
170
+ .fw-facet.red-border { border-color: #4a1a1a; }
171
+ .fw-facet.red-border.expanded { border-color: #b54040; }
172
+ .fw-facet.purple-border { border-color: #2a1a4a; }
173
+ .fw-facet.purple-border.expanded { border-color: #7040c0; }
174
+
175
+ .fw-facet-top {
176
+ display: flex;
177
+ align-items: center;
178
+ padding: 18px 20px;
179
+ gap: 16px;
180
+ }
181
+ .fw-ring-wrap { position: relative; width: 52px; height: 52px; flex-shrink: 0; }
182
+ .fw-ring-wrap svg { position: absolute; inset: 0; transform: rotate(-90deg); }
183
+ .fw-ring-score {
184
+ position: absolute;
185
+ inset: 0;
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ font-family: 'Syne', sans-serif;
190
+ font-size: 14px;
191
+ font-weight: 800;
192
+ color: #f0f6ff;
193
+ }
194
+ .fw-facet-info { flex: 1; }
195
+ .fw-facet-name {
196
+ font-family: 'Syne', sans-serif;
197
+ font-size: 16px;
198
+ font-weight: 700;
199
+ color: #e2e8f0;
200
+ margin-bottom: 4px;
201
+ }
202
+ .fw-facet-sub { font-size: 12px; color: #4a5f72; display: flex; align-items: center; gap: 8px; }
203
+ .fw-fidelity {
204
+ font-size: 9px;
205
+ padding: 2px 8px;
206
+ border-radius: 100px;
207
+ font-weight: 600;
208
+ letter-spacing: .06em;
209
+ text-transform: uppercase;
210
+ }
211
+ .fw-fidelity.high { background: rgba(74,158,255,.15); color: #4a9eff; border: 1px solid rgba(74,158,255,.3); }
212
+ .fw-fidelity.moderate { background: rgba(46,204,140,.12); color: #2ecc8c; border: 1px solid rgba(46,204,140,.3); }
213
+ .fw-fidelity.emerging { background: rgba(245,166,35,.12); color: #f5a623; border: 1px solid rgba(245,166,35,.3); }
214
+ .fw-fidelity.exploring { background: rgba(148,90,220,.12); color: #9a5adc; border: 1px solid rgba(148,90,220,.3); }
215
+
216
+ .fw-facet-right { text-align: right; }
217
+ .fw-strategy-name {
218
+ font-family: 'Syne', sans-serif;
219
+ font-size: 14px;
220
+ font-weight: 700;
221
+ color: #c8dff0;
222
+ margin-bottom: 3px;
223
+ }
224
+ .fw-strategy-mu {
225
+ font-family: 'DM Mono', monospace;
226
+ font-size: 11px;
227
+ color: #3a6a9a;
228
+ }
229
+ .fw-chevron {
230
+ color: #3a6a9a;
231
+ font-size: 12px;
232
+ margin-left: 10px;
233
+ transition: transform .25s;
234
+ }
235
+ .fw-facet.open .fw-chevron { transform: rotate(180deg); }
236
+
237
+ /* Expanded content */
238
+ .fw-facet-body {
239
+ display: none;
240
+ padding: 0 20px 20px;
241
+ border-top: 1px solid #111d2a;
242
+ }
243
+ .fw-facet.open .fw-facet-body { display: block; }
244
+
245
+ .fw-beta-row {
246
+ display: grid;
247
+ grid-template-columns: 1fr 1fr;
248
+ gap: 16px;
249
+ margin: 18px 0 16px;
250
+ }
251
+ .fw-beta-cell { }
252
+ .fw-beta-label {
253
+ font-family: 'DM Mono', monospace;
254
+ font-size: 9px;
255
+ letter-spacing: .12em;
256
+ text-transform: uppercase;
257
+ margin-bottom: 8px;
258
+ }
259
+ .fw-beta-label .param { font-weight: 600; }
260
+ .fw-beta-svg { width: 100%; height: 80px; }
261
+
262
+ .fw-insight {
263
+ border-radius: 10px;
264
+ padding: 14px 16px;
265
+ margin-bottom: 12px;
266
+ }
267
+ .fw-insight-hdr {
268
+ font-family: 'DM Mono', monospace;
269
+ font-size: 9px;
270
+ letter-spacing: .16em;
271
+ text-transform: uppercase;
272
+ margin-bottom: 8px;
273
+ font-weight: 600;
274
+ }
275
+ .fw-insight-text { font-size: 13.5px; line-height: 1.75; color: #8fa8c0; }
276
+
277
+ .fw-evidence {
278
+ display: flex;
279
+ align-items: flex-start;
280
+ gap: 8px;
281
+ font-size: 12px;
282
+ color: #4a6a82;
283
+ }
284
+ .fw-evidence::before { content: '●'; font-size: 8px; margin-top: 3px; flex-shrink: 0; }
285
+
286
+ /* Footer note */
287
+ .fw-footer-note {
288
+ margin-top: 32px;
289
+ padding: 18px 20px;
290
+ background: #080d14;
291
+ border: 1px solid #111d2a;
292
+ border-radius: 12px;
293
+ font-size: 13px;
294
+ color: #3a5a72;
295
+ line-height: 1.7;
296
+ }
297
+ </style>
298
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
299
+ <style>
300
+ :root {
301
+ --bg: #f5f7fb;
302
+ --s: #ffffff;
303
+ --s2: #fafbfe;
304
+ --s3: #eef2f8;
305
+ --bd: #dde4ef;
306
+ --ac: #615cf6;
307
+ --g: #178a59;
308
+ --r: #c94545;
309
+ --y: #a97700;
310
+ --c: #2f9fb2;
311
+ --tx: #1f2430;
312
+ --dm: #5f6b7c;
313
+ --dmr: #94a0b2;
314
+ --shadow: 0 10px 30px rgba(30, 41, 59, .08);
315
+
316
+ --c0:#615cf6; --c0a:rgba(97,92,246,.10);
317
+ --c1:#e46b6b; --c1a:rgba(228,107,107,.10);
318
+ --c2:#24b06f; --c2a:rgba(36,176,111,.10);
319
+ --c3:#d7a325; --c3a:rgba(215,163,37,.10);
320
+ --c4:#3aa7b8; --c4a:rgba(58,167,184,.10);
321
+ --c5:#9a72ff; --c5a:rgba(154,114,255,.10);
322
+ --c6:#ff9f1c; --c6a:rgba(255,159,28,.10);
323
+ }
324
+ *{box-sizing:border-box;margin:0;padding:0}
325
+ body{
326
+ background:var(--bg);color:var(--tx);
327
+ font-family:'Inter',system-ui,sans-serif;
328
+ height:100vh;overflow:hidden;
329
+ display:grid;
330
+ grid-template-rows:52px 1fr;
331
+ grid-template-columns:1fr 296px 272px;
332
+ }
333
+
334
+ /* Demo mode: collapse sidebars to make the split chat obvious */
335
+ body.hideSidebars{ grid-template-columns:1fr 0 0; }
336
+ body.hideSidebars .sa, body.hideSidebars .sb{ display:none; }
337
+
338
+ /* HEADER */
339
+ header{
340
+ grid-column:1/-1;
341
+ background:rgba(255,255,255,.86);border-bottom:1px solid var(--bd);backdrop-filter:blur(10px);
342
+ display:flex;align-items:center;padding:0 20px;gap:10px;
343
+ }
344
+ .logo{font-family:'Syne',sans-serif;font-weight:800;font-size:14px;letter-spacing:.06em}
345
+ .logo em{color:var(--ac);font-style:normal}
346
+ .pill{font-size:9px;font-weight:600;padding:3px 8px;border-radius:100px;
347
+ letter-spacing:.1em;text-transform:uppercase}
348
+ .pa{background:var(--c0a);border:1px solid rgba(108,99,255,.3);color:var(--c0)}
349
+ .pg{background:var(--c2a);border:1px solid rgba(61,255,160,.3);color:var(--c2)}
350
+ .hr{margin-left:auto;display:flex;align-items:center;gap:10px}
351
+ .dot{width:7px;height:7px;border-radius:50%;background:var(--dmr);transition:background .3s}
352
+ .dot.live{background:var(--g);box-shadow:0 0 8px var(--g)}
353
+ .dot.err{background:var(--r)}
354
+ .olbl{font-size:10px;color:var(--dm);font-weight:600}
355
+ .bsm{font-family:'Inter',system-ui,sans-serif;font-size:10px;padding:4px 11px;
356
+ background:transparent;border:1px solid var(--bd);color:var(--dm);
357
+ border-radius:6px;cursor:pointer;transition:all .18s}
358
+ .bsm:hover{border-color:var(--r);color:var(--r)}
359
+
360
+ /* CHAT */
361
+ .panehdr{padding:14px 18px;border-bottom:1px solid var(--bd);background:rgba(255,255,255,.8);display:flex;align-items:center;gap:8px}
362
+ .panehdr .ttl{font-family:'Syne',sans-serif;font-weight:800;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--dm)}
363
+ .pane{display:flex;flex-direction:column;overflow:hidden;min-height:0}
364
+ .pane .msgs{padding:16px 18px;flex:1;overflow-y:auto}
365
+ .pane + .pane{border-left:1px solid var(--bd)}
366
+
367
+ /* Make the baseline/adaptive panes visually distinct */
368
+ .pane.baseline{background:linear-gradient(180deg, rgba(97,92,246,.03), transparent 26%)}
369
+ .pane.adaptive{background:linear-gradient(180deg, rgba(36,176,111,.04), transparent 26%)}
370
+ .pane.baseline .panehdr{border-bottom:1px solid rgba(255,255,255,.06)}
371
+ .pane.adaptive .panehdr{border-bottom:1px solid rgba(61,255,160,.12)}
372
+ .pane + .pane{border-left:1px solid rgba(97,92,246,.12)}
373
+
374
+ .chat{display:grid;grid-template-rows:1fr auto;grid-template-columns:1fr 1fr;overflow:hidden;border-right:1px solid var(--bd)}
375
+ body.hideBaseline .chat{grid-template-columns:1fr}
376
+ body.hideBaseline .pane.baseline{display:none}
377
+ .msgs{flex:1;overflow-y:auto;padding:24px;display:flex;flex-direction:column;gap:18px;scroll-behavior:smooth}
378
+ .msgs::-webkit-scrollbar{width:3px}
379
+ .msgs::-webkit-scrollbar-thumb{background:var(--bd);border-radius:2px}
380
+
381
+ .msg{display:flex;flex-direction:column;gap:5px;animation:fu .26s ease}
382
+ @keyframes fu{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
383
+ .msg.u{align-items:flex-end}
384
+ .msg.a{align-items:flex-start}
385
+ .who{font-size:10px;color:var(--dm);letter-spacing:.08em;text-transform:uppercase;font-weight:600}
386
+ .bub{max-width:82%;padding:14px 17px;border-radius:16px;font-size:15px;line-height:1.75;box-shadow:var(--shadow)}
387
+ .msg.u .bub{background:linear-gradient(135deg,#736eff,#615cf6);color:#fff;border-bottom-right-radius:6px}
388
+ .msg.a .bub{background:#fff;border:1px solid var(--bd);border-bottom-left-radius:6px}
389
+
390
+ .bub table{
391
+ width:100%;
392
+ border-collapse:collapse;
393
+ margin-top:8px;
394
+ font-size:13px;
395
+ background:#fff;
396
+ }
397
+ .bub th,.bub td{
398
+ border:1px solid var(--bd);
399
+ padding:9px 10px;
400
+ text-align:left;
401
+ vertical-align:top;
402
+ }
403
+ .bub th{
404
+ background:#f3f6fb;
405
+ color:var(--tx);
406
+ }
407
+ .bub pre{
408
+ margin:8px 0 0;
409
+ padding:12px 14px;
410
+ border-radius:12px;
411
+ background:#f8faff;
412
+ border:1px solid var(--bd);
413
+ overflow-x:auto;
414
+ white-space:pre-wrap;
415
+ font-size:13px;
416
+ line-height:1.6;
417
+ }
418
+ .bub code{
419
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
420
+ }
421
+ .viz-wrap{margin-top:4px}
422
+ .widget-mount{margin-top:10px}
423
+ .widget-frame{
424
+ width:100%;
425
+ border:0;
426
+ border-radius:12px;
427
+ background:transparent;
428
+ opacity:0;
429
+ transition:opacity .18s ease;
430
+ }
431
+ .widget-frame.ready{
432
+ opacity:1;
433
+ }
434
+ .widget-skeleton{
435
+ border:1px solid var(--bd);
436
+ border-radius:12px;
437
+ background:linear-gradient(180deg,#fbfcff,#f7f9fd);
438
+ padding:14px;
439
+ margin-bottom:4px;
440
+ }
441
+ .widget-skeleton .line{
442
+ height:10px;
443
+ border-radius:999px;
444
+ background:linear-gradient(90deg,#eef2f8 0%,#e3e9f3 50%,#eef2f8 100%);
445
+ background-size:200% 100%;
446
+ animation:sk 1.1s ease-in-out infinite;
447
+ margin-bottom:9px;
448
+ }
449
+ .widget-skeleton .line:last-child{margin-bottom:0}
450
+ .widget-skeleton .w60{width:60%}
451
+ .widget-skeleton .w80{width:80%}
452
+ .widget-skeleton .w95{width:95%}
453
+
454
+ .ws-root{display:flex;flex-direction:column;gap:12px;margin-top:10px}
455
+ .ws-text{font-size:13px;color:var(--dm);line-height:1.6}
456
+ .ws-kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px}
457
+ .ws-kpi{padding:12px}
458
+ .ws-kpi-lbl{font-size:11px;color:var(--dm);margin-bottom:6px}
459
+ .ws-kpi-val{font-size:20px;font-weight:700;color:var(--ink)}
460
+ .ws-kpi-val.positive{color:var(--g)}
461
+ .ws-kpi-val.negative{color:var(--r)}
462
+ .ws-kpi-val.neutral{color:var(--ink)}
463
+ .ws-table-el{width:100%;border-collapse:collapse}
464
+ .ws-table-el th,.ws-table-el td{border-bottom:1px solid rgba(15,23,42,.08);padding:8px 10px;text-align:left;font-size:12.5px}
465
+ .ws-table-el th{font-size:11px;color:var(--dm);text-transform:uppercase;letter-spacing:.04em}
466
+ .ws-actions{display:flex;gap:8px;flex-wrap:wrap}
467
+ .ws-schema-preview{margin-top:10px;padding:10px 12px;border-radius:10px;border:0.5px solid rgba(0,0,0,0.08);background:rgba(0,0,0,0.03);white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:rgba(0,0,0,0.65)}
468
+ @keyframes sk{
469
+ 0%{background-position:180% 0}
470
+ 100%{background-position:-20% 0}
471
+ }
472
+
473
+
474
+ .meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
475
+ .stag{font-size:10px;padding:4px 9px;border-radius:999px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
476
+ .el{font-size:10px;color:var(--dm)}
477
+ .notice{font-size:9px;padding:2px 9px;border-radius:100px;letter-spacing:.04em}
478
+ .notice.explore{background:var(--c3a);border:1px solid rgba(255,217,61,.3);color:var(--y)}
479
+ .notice.override{background:var(--c5a);border:1px solid rgba(166,107,255,.3);color:var(--c5)}
480
+
481
+ .autob{font-size:9px;padding:2px 9px;border-radius:100px;letter-spacing:.04em;animation:fu .3s ease}
482
+ .autob.pos{background:var(--c2a);border:1px solid rgba(61,255,160,.3);color:var(--g)}
483
+ .autob.neg{background:var(--c1a);border:1px solid rgba(255,94,94,.3);color:var(--r)}
484
+
485
+ .rbs{display:flex;gap:4px}
486
+ .rb{font-family:'Inter',system-ui,sans-serif;font-size:10px;padding:2px 9px;border-radius:100px;
487
+ cursor:pointer;border:1px solid var(--bd);background:transparent;color:var(--dm);transition:all .15s}
488
+ .rb:hover{border-color:var(--g);color:var(--g)}
489
+ .rb.neg:hover{border-color:var(--r);color:var(--r)}
490
+ .rb.used{opacity:.3;cursor:default;pointer-events:none}
491
+
492
+ .typ{display:flex;gap:5px;padding:12px 15px;background:var(--s2);border:1px solid var(--bd);
493
+ border-radius:13px;border-bottom-left-radius:3px;width:fit-content}
494
+ .typ span{width:5px;height:5px;border-radius:50%;background:var(--dmr);animation:bop 1.1s infinite}
495
+ .typ span:nth-child(2){animation-delay:.18s}
496
+ .typ span:nth-child(3){animation-delay:.36s}
497
+ @keyframes bop{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px);background:var(--ac)}}
498
+
499
+ .errbub{background:rgba(255,94,94,.08);border:1px solid rgba(255,94,94,.3);
500
+ border-radius:10px;padding:10px 14px;font-size:12px;color:var(--r)}
501
+
502
+ .empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
503
+ gap:12px;color:var(--dm);text-align:center;padding:40px}
504
+ .ei{font-size:34px;opacity:.35}
505
+ .et{font-family:'Syne',sans-serif;font-weight:700;font-size:16px;color:var(--tx)}
506
+ .eb{font-size:13px;line-height:1.7;max-width:34ch}
507
+
508
+ .inrow{padding:16px 18px;border-top:1px solid var(--bd);display:flex;gap:10px;background:rgba(255,255,255,.94);backdrop-filter:blur(10px)}
509
+ textarea#inp{flex:1;background:var(--s2);border:1px solid var(--bd);border-radius:9px;
510
+ padding:9px 13px;color:var(--tx);font-family:'Inter',system-ui,sans-serif;font-size:12.5px;
511
+ resize:none;outline:none;line-height:1.5;min-height:42px;max-height:110px;transition:border-color .18s}
512
+ textarea#inp:focus{border-color:var(--ac)}
513
+ textarea#inp::placeholder{color:#98a2b3}
514
+ #send{font-family:'Syne',sans-serif;font-weight:700;font-size:12px;padding:0 20px;
515
+ background:var(--ac);color:#fff;border:none;border-radius:14px;cursor:pointer;
516
+ letter-spacing:.04em;transition:background .18s, transform .18s;white-space:nowrap;box-shadow:0 8px 20px rgba(97,92,246,.22)}
517
+ #send:hover{background:#5a52e8}
518
+ #send:disabled{opacity:.4;cursor:not-allowed}
519
+
520
+ /* SIDEBARS */
521
+ .sa,.sb{display:flex;flex-direction:column;overflow:hidden;background:#fff;border-left:1px solid var(--bd)}
522
+ .pnl{padding:14px 14px 12px;border-bottom:1px solid var(--bd)}
523
+ .pt{font-family:'Syne',sans-serif;font-size:10px;font-weight:700;letter-spacing:.1em;
524
+ text-transform:uppercase;color:var(--dm);margin-bottom:10px}
525
+
526
+ .abox{background:#fff;border:1px solid var(--bd);border-radius:12px;padding:12px;transition:all .35s;box-shadow:0 2px 10px rgba(15,23,42,.03)}
527
+ .an{font-family:'Syne',sans-serif;font-weight:700;font-size:14px;margin-bottom:4px}
528
+ .ad{font-size:12px;color:var(--dm);line-height:1.65}
529
+
530
+ /* Bars */
531
+ .bl{display:flex;flex-direction:column;gap:7px}
532
+ .br{display:flex;flex-direction:column;gap:3px}
533
+ .bh{display:flex;justify-content:space-between;align-items:center}
534
+ .bn{font-size:10px;color:var(--dm);letter-spacing:.02em}
535
+ .bv{font-size:10px;font-weight:700}
536
+ .bt{height:6px;background:var(--s3);border-radius:999px;overflow:hidden}
537
+ .bf{height:100%;border-radius:2px;transition:width .5s cubic-bezier(.16,1,.3,1)}
538
+ .bu{display:flex;justify-content:space-between;align-items:center}
539
+ .ul{font-size:9px;color:var(--dmr)}
540
+ .ut{width:56px;height:4px;background:var(--s3);border-radius:999px;overflow:hidden}
541
+ .uf{height:100%;background:rgba(255,255,255,.1);transition:width .5s ease}
542
+
543
+ /* Inference table */
544
+ .ir{display:flex;justify-content:space-between;align-items:flex-start;font-size:11px;
545
+ padding:8px 10px;border-radius:10px;background:var(--s2);margin-bottom:6px;gap:10px}
546
+ .ir.sel{background:var(--c0a);border:1px solid rgba(108,99,255,.25)}
547
+ .in_{color:var(--dm)}
548
+ .iv{font-weight:600}
549
+
550
+ /* Feature grid */
551
+ .fg{display:grid;grid-template-columns:1fr 1fr;gap:8px}
552
+ .fi{background:var(--s2);border:1px solid var(--bd);border-radius:10px;padding:8px 10px}
553
+ .fk{font-size:9px;color:var(--dm);margin-bottom:3px}
554
+ .fv{font-size:12px;font-weight:600}
555
+
556
+ /* Global counters */
557
+ .gc{display:flex;justify-content:space-between;margin-bottom:8px}
558
+ .gi{text-align:center}
559
+ .gv{font-family:'Syne',sans-serif;font-weight:700;font-size:22px}
560
+ .gk{font-size:9px;color:var(--dmr);letter-spacing:.05em;text-transform:uppercase}
561
+
562
+ /* User B */
563
+ .ubh{display:flex;align-items:center;gap:7px;margin-bottom:9px}
564
+ .uba{width:24px;height:24px;border-radius:50%;
565
+ background:linear-gradient(135deg,var(--y),var(--c));
566
+ display:flex;align-items:center;justify-content:center;
567
+ font-size:11px;font-weight:700;color:#000;flex-shrink:0}
568
+ .ubd{font-size:11px;color:var(--dm);line-height:1.6}
569
+
570
+ /* Log */
571
+ .lg{flex:1;overflow-y:auto;padding:10px 14px 14px;display:flex;flex-direction:column;gap:6px}
572
+ .lg::-webkit-scrollbar{width:2px}
573
+ .lg::-webkit-scrollbar-thumb{background:var(--bd)}
574
+ .le{font-size:10px;padding:8px 10px;border-radius:10px;background:var(--s2);
575
+ border-left:3px solid transparent;animation:fl .2s ease;line-height:1.5}
576
+ @keyframes fl{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:translateX(0)}}
577
+ .lr{float:right;font-weight:600}
578
+ .ls{font-size:9px;color:var(--dm);margin-top:4px}
579
+
580
+ /* Color tokens */
581
+ .v0{color:var(--c0)}.v1{color:var(--c1)}.v2{color:var(--c2)}.v3{color:var(--c3)}.v4{color:var(--c4)}.v5{color:var(--c5)}.v6{color:var(--c6)}
582
+ .f0{background:var(--c0)}.f1{background:var(--c1)}.f2{background:var(--c2)}.f3{background:var(--c3)}.f4{background:var(--c4)}.f5{background:var(--c5)}.f6{background:var(--c6)}
583
+ .stag.v0{background:var(--c0a);border:1px solid rgba(108,99,255,.3)}
584
+ .stag.v1{background:var(--c1a);border:1px solid rgba(255,107,107,.3)}
585
+ .stag.v2{background:var(--c2a);border:1px solid rgba(61,255,160,.3)}
586
+ .stag.v3{background:var(--c3a);border:1px solid rgba(255,217,61,.3)}
587
+ .stag.v4{background:var(--c4a);border:1px solid rgba(78,205,196,.3)}
588
+ .stag.v5{background:var(--c5a);border:1px solid rgba(166,107,255,.3)}
589
+ .stag.v6{background:var(--c6a);border:1px solid rgba(255,159,28,.3)}
590
+
591
+ .sec{border-bottom:1px solid var(--bd);background:#fff}
592
+ .sec summary{list-style:none;cursor:pointer;padding:14px 14px 12px;display:flex;align-items:center;justify-content:space-between;
593
+ font-family:'Syne',sans-serif;font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--dm)}
594
+ .sec summary::-webkit-details-marker{display:none}
595
+ .sec summary::after{content:'▾';font-size:12px;color:var(--dmr);transition:transform .18s ease}
596
+ .sec:not([open]) summary::after{transform:rotate(-90deg)}
597
+ .sec .sec-body{padding:0 14px 14px}
598
+ .sec .sec-body .pt{display:none}
599
+ .sec.compact .sec-body{padding-top:2px}
600
+ .sec .lg{padding:0}
601
+
602
+ /* Preference modal */
603
+ .modal-bg{position:fixed;inset:0;background:rgba(241,245,249,.76);backdrop-filter:blur(8px);
604
+ display:flex;align-items:center;justify-content:center;z-index:100}
605
+ .modal{background:#fff;border:1px solid var(--bd);border-radius:20px;padding:30px 32px;
606
+ max-width:560px;width:92%;display:flex;flex-direction:column;gap:18px;box-shadow:0 24px 60px rgba(15,23,42,.12)}
607
+ .modal h2{font-family:'Syne',sans-serif;font-weight:800;font-size:17px}
608
+ .modal p{font-size:13px;color:var(--dm);line-height:1.7}
609
+ .pref-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
610
+ .pc{border:1px solid var(--bd);border-radius:12px;padding:12px 13px;cursor:pointer;
611
+ background:transparent;color:var(--tx);text-align:left;transition:all .18s;font-family:'Inter',system-ui,sans-serif}
612
+ .pc:hover{border-color:var(--ac);background:var(--c0a)}
613
+ .pc.sel{border-color:var(--ac);background:var(--c0a)}
614
+ .pc .pn{font-family:'Syne',sans-serif;font-weight:700;font-size:11px;margin-bottom:3px}
615
+ .pc .pd{font-size:11px;color:var(--dm);line-height:1.55}
616
+ .modal-footer{display:flex;gap:8px;justify-content:flex-end}
617
+ .btn-skip{font-family:'Inter',system-ui,sans-serif;font-size:10px;padding:7px 16px;
618
+ background:transparent;border:1px solid var(--bd);color:var(--dm);border-radius:7px;cursor:pointer}
619
+ .btn-skip:hover{border-color:var(--dm)}
620
+ .btn-go{font-family:'Syne',sans-serif;font-weight:700;font-size:12px;padding:10px 20px;
621
+ background:var(--ac);color:#fff;border:none;border-radius:7px;cursor:pointer;letter-spacing:.04em}
622
+ .btn-go:hover{background:#5a52e8}
623
+ .btn-go:disabled{opacity:.4;cursor:not-allowed}
624
+ </style>
625
+ </head>
626
+ <body>
627
+
628
+ <!-- PREFERENCE MODAL (shown on first visit or after reset) -->
629
+ <div class="modal-bg" id="prefModal" style="display:none">
630
+ <div class="modal">
631
+ <h2>How do you like responses? <span style="opacity:.4">✦</span></h2>
632
+ <p>Pick one or more styles — the engine will warm-start your posterior from these before your first message. You can skip and let it learn from scratch instead.</p>
633
+ <div class="pref-grid">
634
+ <button class="pc" data-s="structured_bullets" onclick="togglePref(this)">
635
+ <div class="pn v0">Structured Bullets</div>
636
+ <div class="pd">Clear lists, scannable, organized</div>
637
+ </button>
638
+ <button class="pc" data-s="narrative_prose" onclick="togglePref(this)">
639
+ <div class="pn v1">Narrative Prose</div>
640
+ <div class="pd">Flowing paragraphs, warm tone</div>
641
+ </button>
642
+ <button class="pc" data-s="concise_direct" onclick="togglePref(this)">
643
+ <div class="pn v2">Concise & Direct</div>
644
+ <div class="pd">1–3 punchy sentences, no fluff</div>
645
+ </button>
646
+ <button class="pc" data-s="socratic_questions" onclick="togglePref(this)">
647
+ <div class="pn v3">Socratic Questions</div>
648
+ <div class="pd">Asks clarifying questions first</div>
649
+ </button>
650
+ <button class="pc" data-s="step_by_step" onclick="togglePref(this)" style="grid-column:1/-1">
651
+ <div class="pn v4">Step-by-Step</div>
652
+ <div class="pd">Numbered, procedural, methodical</div>
653
+ </button>
654
+ <button class="pc" data-s="comparison_table" onclick="togglePref(this)">
655
+ <div class="pn v5">Comparison Table</div>
656
+ <div class="pd">Pros/cons or side-by-side comparison</div>
657
+ </button>
658
+ <button class="pc" data-s="visualization" onclick="togglePref(this)">
659
+ <div class="pn v6">Visualization</div>
660
+ <div class="pd">Quick ASCII chart / simple visualization</div>
661
+ </button>
662
+ </div>
663
+ <div style="display:flex;align-items:center;gap:8px;margin-top:-6px">
664
+ <input type="checkbox" id="prefLock" style="accent-color:var(--ac)">
665
+ <label for="prefLock" style="font-size:10px;color:var(--dm)">Lock to ONE style (turns off exploration)</label>
666
+ </div>
667
+ <div class="modal-footer">
668
+ <button class="btn-skip" onclick="skipPref()">Skip — learn from scratch</button>
669
+ <button class="btn-go" id="prefGo" onclick="submitPref()" disabled>Apply Preferences →</button>
670
+ </div>
671
+ </div>
672
+ </div>
673
+
674
+ <header>
675
+ <div class="logo">Adaptive<em>.</em>Engine</div>
676
+ <div class="pill pa">Hierarchical Bayesian</div>
677
+ <div class="pill pg">Rich Signals · v3</div>
678
+ <div class="nav-tabs">
679
+ <button class="nav-tab active" id="tab-main" onclick="switchTab('main')">Chat</button>
680
+ <button class="nav-tab fw-tab" id="tab-fw" onclick="switchTab('fw')">Future Work</button>
681
+ </div>
682
+ <div class="hr">
683
+ <button class="bsm" id="toggle-sidebars-btn" onclick="toggleSidebars()">Toggle tech panels</button>
684
+ <button class="bsm" id="toggle-baseline-btn" onclick="toggleBaseline()">Hide baseline</button>
685
+ <div class="dot" id="dot"></div>
686
+ <span class="olbl" id="olbl">checking…</span>
687
+ <button class="bsm" onclick="reset()">↺ Reset</button>
688
+ </div>
689
+ </header>
690
+
691
+
692
+ <!-- CHAT -->
693
+ <div class="chat">
694
+ <!-- Baseline pane -->
695
+ <div class="pane baseline">
696
+ <div class="panehdr">
697
+ <div class="ttl">Baseline · No Bandit</div>
698
+ <span class="pill pa" style="margin-left:auto;border-color:rgba(108,99,255,.25);color:var(--dm);background:transparent">normal LLM</span>
699
+ </div>
700
+ <div class="msgs" id="msgs_plain">
701
+ <div class="empty" id="emp_plain">
702
+ <div class="ei">◻</div>
703
+ <div class="et">Baseline chat</div>
704
+ <div class="eb">Same message, but without strategy selection or adaptive formatting.</div>
705
+ </div>
706
+ </div>
707
+ <div style="padding:6px 18px 0;display:none" id="tw_plain">
708
+ <div class="typ"><span></span><span></span><span></span></div>
709
+ </div>
710
+ </div>
711
+
712
+ <!-- Adaptive pane -->
713
+ <div class="pane adaptive">
714
+ <div class="panehdr">
715
+ <div class="ttl">Adaptive · Bandit Layer</div>
716
+ <span class="pill pg" style="margin-left:auto;border-color:rgba(61,255,160,.25);color:var(--dm);background:transparent">thompson sampling</span>
717
+ </div>
718
+ <div class="msgs" id="msgs_adapt">
719
+ <div class="empty" id="emp_adapt">
720
+ <div class="ei">⬡</div>
721
+ <div class="et">Adaptive chat</div>
722
+ <div class="eb">Chooses a presentation strategy per turn using contextual Thompson Sampling + rich feedback signals.</div>
723
+ </div>
724
+ </div>
725
+ <div style="padding:6px 18px 0;display:none" id="tw_adapt">
726
+ <div class="typ"><span></span><span></span><span></span></div>
727
+ </div>
728
+ </div>
729
+
730
+ <!-- Shared input row spans both panes -->
731
+ <div class="inrow" style="grid-column:1/-1">
732
+ <textarea id="inp" placeholder="Type a message… (sent to both panes)" rows="1"
733
+ onkeydown="onK(event)" oninput="rsz(this)"></textarea>
734
+ <button id="send" onclick="send()">SEND →</button>
735
+ </div>
736
+ </div>
737
+
738
+ <!-- SIDEBAR A — User posterior -->
739
+ <div class="sa">
740
+ <details class="sec compact" open>
741
+ <summary>Active Strategy</summary>
742
+ <div class="sec-body">
743
+ <div class="abox">
744
+ <div class="an v0" id="an">—</div>
745
+ <div class="ad" id="ad">Waiting for first message…</div>
746
+ </div>
747
+ </div>
748
+ </details>
749
+
750
+ <details class="sec" open>
751
+ <summary>Your Posterior — P(r=1 | x, a=k)</summary>
752
+ <div class="sec-body">
753
+ <div class="bl" id="ub"></div>
754
+ </div>
755
+ </details>
756
+
757
+ <details class="sec" id="ip" style="display:none">
758
+ <summary>Thompson Sampling — This Turn</summary>
759
+ <div class="sec-body">
760
+ <div id="it"></div>
761
+ </div>
762
+ </details>
763
+
764
+ <details class="sec" id="fp" style="display:none">
765
+ <summary>Feature Vector x ∈ ℝ¹⁰</summary>
766
+ <div class="sec-body">
767
+ <div class="fg" id="fg"></div>
768
+ </div>
769
+ </details>
770
+
771
+ <details class="sec" open>
772
+ <summary>Reward Log</summary>
773
+ <div class="sec-body">
774
+ <div class="lg" id="lg"><p style="font-size:10px;color:var(--dmr);text-align:center;padding:16px">No interactions yet</p></div>
775
+ </div>
776
+ </details>
777
+ </div>
778
+
779
+ <!-- SIDEBAR B — Global + User B -->
780
+ <div class="sb">
781
+ <details class="sec">
782
+ <summary>Global Prior — All Users</summary>
783
+ <div class="sec-body">
784
+ <p style="font-size:11px;color:var(--dm);margin-bottom:10px;line-height:1.6">
785
+ Shared knowledge. Every reward feeds back here at α=0.05.
786
+ </p>
787
+ <div class="gc">
788
+ <div class="gi"><div class="gv" id="gn">0</div><div class="gk">updates</div></div>
789
+ <div class="gi"><div class="gv" id="nu">1</div><div class="gk">users</div></div>
790
+ </div>
791
+ <div class="bl" id="gb"></div>
792
+ </div>
793
+ </details>
794
+
795
+ <details class="sec">
796
+ <summary>User B — New User Inheriting Prior</summary>
797
+ <div class="sec-body">
798
+ <div class="ubh">
799
+ <div class="uba">B</div>
800
+ <div class="ubd">Fresh session. Starts from the current global posterior. Bars only move when the global prior changes — after a real reward update.</div>
801
+ </div>
802
+ <div class="bl" id="bb"></div>
803
+ </div>
804
+ </details>
805
+ </div>
806
+
807
+ <script>
808
+ // ── Persistent UID — survives page refreshes, resets only on explicit Reset
809
+ let UID = localStorage.getItem('ape_uid');
810
+ if (!UID) { UID = 'u_' + Math.random().toString(36).slice(2,8); localStorage.setItem('ape_uid', UID); }
811
+ const SN = ['structured_bullets','narrative_prose','concise_direct','socratic_questions','step_by_step','comparison_table','visualization'];
812
+ const SL = {
813
+ structured_bullets:'Structured Bullets',
814
+ narrative_prose:'Narrative Prose',
815
+ concise_direct:'Concise Direct',
816
+ socratic_questions:'Socratic Questions',
817
+ step_by_step:'Step-by-Step',
818
+ comparison_table:'Comparison Table',
819
+ visualization:'Visualization',
820
+ };
821
+ const FN = ['msg_len','word_ct','has_?','is_long','formal','avg_rwd','msg_num','last_s','trend','noise'];
822
+
823
+ // ── Build bar lists once
824
+ function mkBars(id) {
825
+ document.getElementById(id).innerHTML = SN.map((s,i) => `
826
+ <div class="br" id="${id}_r_${s}" style="opacity:.5">
827
+ <div class="bh">
828
+ <span class="bn v${i}">${SL[s]}</span>
829
+ <span class="bv v${i}" id="${id}_v_${s}">—</span>
830
+ </div>
831
+ <div class="bt"><div class="bf f${i}" id="${id}_f_${s}" style="width:0%"></div></div>
832
+ <div class="bu">
833
+ <span class="ul">uncertainty</span>
834
+ <div class="ut"><div class="uf" id="${id}_u_${s}" style="width:50%"></div></div>
835
+ </div>
836
+ </div>`).join('');
837
+ }
838
+ mkBars('ub'); mkBars('gb'); mkBars('bb');
839
+
840
+ // ── Update bars — only called when we have fresh data from a real event
841
+ function updBars(id, data, sel) {
842
+ SN.forEach(s => {
843
+ const d = data[s]; if (!d) return;
844
+ const pct = Math.round(d.r * 100);
845
+ const unc = Math.min(d.u / 8, 1);
846
+ const f = document.getElementById(`${id}_f_${s}`);
847
+ const v = document.getElementById(`${id}_v_${s}`);
848
+ const u = document.getElementById(`${id}_u_${s}`);
849
+ const r = document.getElementById(`${id}_r_${s}`);
850
+ if(f) f.style.width = pct + '%';
851
+ if(v) v.textContent = pct + '%';
852
+ if(u) u.style.width = Math.round(unc*100) + '%';
853
+ if(r) r.style.opacity = (!sel || s===sel) ? '1' : '0.45';
854
+ });
855
+ }
856
+
857
+ // ── Preference modal
858
+ let _prefSels = new Set();
859
+
860
+ function togglePref(btn) {
861
+ const s = btn.dataset.s;
862
+ const lock = document.getElementById('prefLock')?.checked;
863
+
864
+ if (lock) {
865
+ // Lock mode: single select
866
+ document.querySelectorAll('.pc.sel').forEach(x => x.classList.remove('sel'));
867
+ _prefSels.clear();
868
+ _prefSels.add(s);
869
+ btn.classList.add('sel');
870
+ } else {
871
+ // Soft mode: multi-select allowed
872
+ if (_prefSels.has(s)) {
873
+ _prefSels.delete(s);
874
+ btn.classList.remove('sel');
875
+ } else {
876
+ _prefSels.add(s);
877
+ btn.classList.add('sel');
878
+ }
879
+ }
880
+ document.getElementById('prefGo').disabled = (_prefSels.size === 0);
881
+ }
882
+
883
+ function skipPref() {
884
+ document.getElementById('prefModal').style.display = 'none';
885
+ }
886
+
887
+ async function submitPref() {
888
+ const chosen = [..._prefSels];
889
+ document.getElementById('prefModal').style.display = 'none';
890
+ try {
891
+ const res = await fetch('/api/preference', {
892
+ method: 'POST', headers: {'Content-Type': 'application/json'},
893
+ body: JSON.stringify({uid: UID, strategies: chosen, lock: document.getElementById('prefLock')?.checked || false})
894
+ });
895
+ const d = await res.json();
896
+ if (d.posterior) {
897
+ updBars('ub', d.posterior, null);
898
+ addLog(chosen[0], 0.9, `cold-start pref: ${chosen.join(', ')}`, 'preference');
899
+ }
900
+ } catch(e) { /* silently continue */ }
901
+ }
902
+
903
+ function showPrefModal() {
904
+ _prefSels = new Set();
905
+ document.querySelectorAll('.pc').forEach(b => b.classList.remove('sel'));
906
+ document.getElementById('prefGo').disabled = true;
907
+ document.getElementById('prefModal').style.display = 'flex';
908
+ }
909
+
910
+ function toggleSidebars() {
911
+ document.body.classList.toggle('hideSidebars');
912
+ }
913
+
914
+ function isBaselineHidden() {
915
+ return document.body.classList.contains('hideBaseline');
916
+ }
917
+
918
+ function syncBaselineToggleUI() {
919
+ const btn = document.getElementById('toggle-baseline-btn');
920
+ if (!btn) return;
921
+ btn.textContent = isBaselineHidden() ? 'Show baseline' : 'Hide baseline';
922
+ }
923
+
924
+ function toggleBaseline() {
925
+ document.body.classList.toggle('hideBaseline');
926
+ try { localStorage.setItem('hideBaseline', isBaselineHidden() ? '1' : '0'); } catch(e) {}
927
+ syncBaselineToggleUI();
928
+ }
929
+
930
+
931
+
932
+ function mkWidgetStreamPreviewSrcdoc(){
933
+ // Token-by-token preview of the incoming widget HTML.
934
+ // When the final widget is ready, the parent will replace `iframe.srcdoc` with the real HTML (with scripts).
935
+ return `<!doctype html>
936
+ <html>
937
+ <head>
938
+ <meta charset="utf-8" />
939
+ <style>
940
+ body{
941
+ margin:0;
942
+ padding:12px 14px;
943
+ font-family:"Segoe UI",system-ui,sans-serif;
944
+ font-size:13px;
945
+ line-height:1.6;
946
+ background:transparent;
947
+ color:#111;
948
+ }
949
+ #pv{min-height:18px}
950
+ #pre{
951
+ margin-top:10px;
952
+ padding:10px 12px;
953
+ border-radius:10px;
954
+ border:0.5px solid rgba(0,0,0,0.08);
955
+ background:rgba(0,0,0,0.03);
956
+ white-space:pre-wrap;
957
+ word-break:break-word;
958
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
959
+ font-size:12.5px;
960
+ }
961
+ </style>
962
+ </head>
963
+ <body>
964
+ <div id="pv"></div>
965
+ <pre id="pre"></pre>
966
+ <script>
967
+ function stripScripts(s){
968
+ return String(s || '').replace(/<script[\\s\\S]*?>[\\s\\S]*?<\\/script>/gi,'');
969
+ }
970
+ const pv = document.getElementById('pv');
971
+ const pre = document.getElementById('pre');
972
+ let buf = '';
973
+
974
+ function postH(){
975
+ try{
976
+ parent.postMessage({ type: 'widget:height', value: document.body.scrollHeight }, '*');
977
+ }catch(e){}
978
+ }
979
+
980
+ const ro = new ResizeObserver(() => postH());
981
+ ro.observe(document.body);
982
+
983
+ window.addEventListener('message', (ev) => {
984
+ const d = ev.data || {};
985
+ if (d.type === 'widget:chunk') {
986
+ const chunk = String(d.html || '');
987
+ buf += chunk;
988
+ // Show raw HTML tokens progressively (always safe).
989
+ pre.textContent = buf;
990
+ // Best-effort HTML preview (scripts stripped) for a nicer look.
991
+ try { pv.innerHTML = stripScripts(buf); } catch(e) {}
992
+ postH();
993
+ }
994
+ });
995
+
996
+ postH();
997
+ <\/script>
998
+ </body>
999
+ </html>`;
1000
+ }
1001
+
1002
+ // ── JSON-first widget renderer (schema v1) ────────────────────────────────
1003
+ function safeParseWidgetSchema(jsonStr) {
1004
+ try {
1005
+ const obj = JSON.parse(String(jsonStr || ''));
1006
+ if (!obj || !Array.isArray(obj.layout)) return null;
1007
+ return obj;
1008
+ } catch (_e) {
1009
+ return null;
1010
+ }
1011
+ }
1012
+
1013
+ function renderWidgetSchemaInto(rootEl, schema) {
1014
+ if (!rootEl) return;
1015
+ rootEl.innerHTML = '';
1016
+ if (!schema || !Array.isArray(schema.layout)) return;
1017
+
1018
+ const wrap = document.createElement('div');
1019
+ wrap.className = 'ws-root';
1020
+
1021
+ for (const block of schema.layout) {
1022
+ try {
1023
+ const el = renderWidgetBlock(block);
1024
+ if (el) wrap.appendChild(el);
1025
+ } catch (_e) {}
1026
+ }
1027
+ rootEl.appendChild(wrap);
1028
+ }
1029
+
1030
+ function renderWidgetBlock(block) {
1031
+ if (!block || typeof block !== 'object') return null;
1032
+ switch (block.type) {
1033
+ case 'text': return renderTextBlock(block);
1034
+ case 'kpi_row': return renderKpiRow(block);
1035
+ case 'chart': return renderChartBlock(block);
1036
+ case 'table': return renderTableBlock(block);
1037
+ case 'action_row': return renderActionRow(block);
1038
+ default: return null;
1039
+ }
1040
+ }
1041
+
1042
+ function renderTextBlock(block) {
1043
+ const div = document.createElement('div');
1044
+ div.className = 'ws-text';
1045
+ div.textContent = String(block.content || '');
1046
+ return div;
1047
+ }
1048
+
1049
+ function renderKpiRow(block) {
1050
+ const row = document.createElement('div');
1051
+ row.className = 'ws-kpi-row';
1052
+ const items = Array.isArray(block.items) ? block.items : [];
1053
+ for (const it of items) {
1054
+ const card = document.createElement('div');
1055
+ card.className = 'ws-kpi card';
1056
+ const lbl = document.createElement('div');
1057
+ lbl.className = 'ws-kpi-lbl';
1058
+ lbl.textContent = String(it?.label || '');
1059
+ const val = document.createElement('div');
1060
+ const tone = (it?.tone === 'positive' || it?.tone === 'negative' || it?.tone === 'neutral') ? it.tone : 'neutral';
1061
+ val.className = 'ws-kpi-val ' + tone;
1062
+ val.textContent = String(it?.value ?? '');
1063
+ card.appendChild(lbl);
1064
+ card.appendChild(val);
1065
+ row.appendChild(card);
1066
+ }
1067
+ return row;
1068
+ }
1069
+
1070
+ function renderChartBlock(block) {
1071
+ const container = document.createElement('div');
1072
+ container.className = 'ws-chart card';
1073
+ if (block.title) {
1074
+ const h = document.createElement('div');
1075
+ h.className = 'card-title';
1076
+ h.textContent = String(block.title);
1077
+ container.appendChild(h);
1078
+ }
1079
+ const pre = document.createElement('pre');
1080
+ pre.className = 'ws-chart-pre';
1081
+ pre.textContent = 'Chart schema received. (Hook up Chart.js/ECharts here.)';
1082
+ container.appendChild(pre);
1083
+ return container;
1084
+ }
1085
+
1086
+ function renderTableBlock(block) {
1087
+ const container = document.createElement('div');
1088
+ container.className = 'ws-table card';
1089
+ if (block.title) {
1090
+ const h = document.createElement('div');
1091
+ h.className = 'card-title';
1092
+ h.textContent = String(block.title);
1093
+ container.appendChild(h);
1094
+ }
1095
+ const table = document.createElement('table');
1096
+ table.className = 'ws-table-el';
1097
+
1098
+ const cols = Array.isArray(block.columns) ? block.columns : [];
1099
+ const rows = Array.isArray(block.rows) ? block.rows : [];
1100
+
1101
+ if (cols.length) {
1102
+ const thead = document.createElement('thead');
1103
+ const tr = document.createElement('tr');
1104
+ for (const c of cols) {
1105
+ const th = document.createElement('th');
1106
+ th.textContent = String(c);
1107
+ tr.appendChild(th);
1108
+ }
1109
+ thead.appendChild(tr);
1110
+ table.appendChild(thead);
1111
+ }
1112
+
1113
+ const tbody = document.createElement('tbody');
1114
+ for (const r of rows) {
1115
+ const tr = document.createElement('tr');
1116
+ const cells = Array.isArray(r) ? r : [];
1117
+ for (const cell of cells) {
1118
+ const td = document.createElement('td');
1119
+ td.textContent = String(cell);
1120
+ tr.appendChild(td);
1121
+ }
1122
+ tbody.appendChild(tr);
1123
+ }
1124
+ table.appendChild(tbody);
1125
+ container.appendChild(table);
1126
+ return container;
1127
+ }
1128
+
1129
+ function renderActionRow(block) {
1130
+ const container = document.createElement('div');
1131
+ container.className = 'ws-actions';
1132
+ const buttons = Array.isArray(block.buttons) ? block.buttons : [];
1133
+ for (const b of buttons) {
1134
+ const btn = document.createElement('button');
1135
+ btn.className = 'btn';
1136
+ btn.type = 'button';
1137
+ btn.textContent = String(b?.label || 'Action');
1138
+ const intent = String(b?.intent || '').trim();
1139
+ btn.onclick = () => {
1140
+ if (!intent) return;
1141
+ // reuse existing bridge: it will feed the input and send()
1142
+ window.postMessage({ type: 'streamlit:setComponentValue', value: intent }, '*');
1143
+ };
1144
+ container.appendChild(btn);
1145
+ }
1146
+ return container;
1147
+ }
1148
+
1149
+ async function send() {
1150
+ const inp = document.getElementById('inp');
1151
+ const msg = inp.value.trim();
1152
+ if (!msg) return;
1153
+
1154
+ document.getElementById('emp_plain').style.display = 'none';
1155
+ document.getElementById('emp_adapt').style.display = 'none';
1156
+ inp.value = '';
1157
+ inp.style.height = 'auto';
1158
+ document.getElementById('send').disabled = true;
1159
+
1160
+ addUserBub(msg);
1161
+
1162
+ if (!isBaselineHidden()) document.getElementById('tw_plain').style.display = 'block';
1163
+ document.getElementById('tw_adapt').style.display = 'block';
1164
+ scrl();
1165
+
1166
+ const mid = 'M' + Date.now();
1167
+
1168
+ // Streaming placeholder for the adaptive (Claude-like) output.
1169
+ const adaptiveEl = mk('div', 'msg a');
1170
+ adaptiveEl.innerHTML = `
1171
+ <div class="who">engine</div>
1172
+ <div class="bub">
1173
+ <div class="stream-resp" id="${mid}_resp"></div>
1174
+ <div class="widget-mount" id="${mid}_wm"></div>
1175
+ </div>
1176
+ <div class="meta">
1177
+ <span class="stag" id="${mid}_stag">—</span>
1178
+ <span class="el" id="${mid}_elapsed">…</span>
1179
+ <div class="rbs" id="${mid}_rbs" style="display:none">
1180
+ <button class="rb" id="${mid}p">👍</button>
1181
+ <button class="rb neg" id="${mid}n">👎</button>
1182
+ </div>
1183
+ </div>`;
1184
+ document.getElementById('msgs_adapt').appendChild(adaptiveEl);
1185
+
1186
+ const respEl = document.getElementById(`${mid}_resp`);
1187
+
1188
+ // Iframe preview shell (gets widget HTML chunks token-by-token).
1189
+ const mount = document.getElementById(`${mid}_wm`);
1190
+ const frame = document.createElement('iframe');
1191
+ frame.className = 'widget-frame';
1192
+ frame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
1193
+ frame.style.height = '260px';
1194
+ frame.srcdoc = mkWidgetStreamPreviewSrcdoc();
1195
+ mount.appendChild(frame);
1196
+
1197
+ let frameReady = false;
1198
+ const widgetQueue = [];
1199
+ frame.addEventListener('load', () => {
1200
+ frameReady = true;
1201
+ while (widgetQueue.length) {
1202
+ const chunk = widgetQueue.shift();
1203
+ try { frame.contentWindow.postMessage({ type: 'widget:chunk', html: chunk }, '*'); } catch(e) {}
1204
+ }
1205
+ }, { once: true });
1206
+
1207
+ const postWidgetChunk = (chunk) => {
1208
+ const s = String(chunk ?? '');
1209
+ if (!s) return;
1210
+ if (!frameReady) widgetQueue.push(s);
1211
+ else {
1212
+ try { frame.contentWindow.postMessage({ type: 'widget:chunk', html: s }, '*'); } catch(e) {}
1213
+ }
1214
+ };
1215
+
1216
+ try {
1217
+ const plainJob = (!isBaselineHidden())
1218
+ ? fetch('/api/chat_plain', {
1219
+ method: 'POST',
1220
+ headers: { 'Content-Type': 'application/json' },
1221
+ body: JSON.stringify({ uid: UID, message: msg })
1222
+ }).then(r => r.json()).catch(() => null)
1223
+ : Promise.resolve(null);
1224
+
1225
+ const adaptRes = await fetch('/api/chat_stream', {
1226
+ method: 'POST',
1227
+ headers: { 'Content-Type': 'application/json' },
1228
+ body: JSON.stringify({ uid: UID, message: msg })
1229
+ });
1230
+
1231
+ if (!adaptRes.ok || !adaptRes.body) {
1232
+ throw new Error('chat_stream failed');
1233
+ }
1234
+
1235
+ const reader = adaptRes.body.getReader();
1236
+ const decoder = new TextDecoder();
1237
+ let buf = '';
1238
+ let didFinalize = false;
1239
+
1240
+ while (true) {
1241
+ const { value, done } = await reader.read();
1242
+ if (done) break;
1243
+
1244
+ buf += decoder.decode(value, { stream: true });
1245
+ const lines = buf.split('\n');
1246
+ buf = lines.pop() || '';
1247
+
1248
+ for (const line of lines) {
1249
+ const t = line.trim();
1250
+ if (!t) continue;
1251
+
1252
+ let evt = null;
1253
+ try { evt = JSON.parse(t); } catch(e) { continue; }
1254
+ if (!evt || !evt.type) continue;
1255
+
1256
+ if (evt.type === 'strategy') {
1257
+ setActive(evt.strategy, evt.instruction);
1258
+ setInference(evt.scores, evt.posterior, evt.strategy, evt);
1259
+ setFeatures(evt.x_vec);
1260
+ updBars('ub', evt.posterior, evt.strategy);
1261
+ updBars('gb', evt.global, null);
1262
+ updBars('bb', evt.userb, null);
1263
+ document.getElementById('gn').textContent = evt.global_n;
1264
+
1265
+ const stag = document.getElementById(`${mid}_stag`);
1266
+ if (stag && SL[evt.strategy]) {
1267
+ const idx = SN.indexOf(evt.strategy);
1268
+ stag.textContent = SL[evt.strategy];
1269
+ stag.className = `stag v${idx}`;
1270
+ }
1271
+ }
1272
+
1273
+ if (evt.type === 'response_delta') {
1274
+ if (respEl) respEl.textContent += String(evt.delta ?? '');
1275
+ }
1276
+
1277
+ if (evt.type === 'widget_delta') {
1278
+ postWidgetChunk(evt.delta);
1279
+ }
1280
+
1281
+ if (evt.type === 'done') {
1282
+ didFinalize = true;
1283
+
1284
+ if (evt.error && respEl) {
1285
+ respEl.innerHTML = `<div class="errbub">⚠ ${esc(String(evt.error))}</div>`;
1286
+ }
1287
+
1288
+ if (evt.response && respEl) respEl.innerHTML = fmt(evt.response, evt.strategy);
1289
+
1290
+ if (document.getElementById('tw_adapt')) document.getElementById('tw_adapt').style.display = 'none';
1291
+
1292
+ // Enable reward buttons.
1293
+ const rbs = document.getElementById(`${mid}_rbs`);
1294
+ if (rbs) {
1295
+ rbs.style.display = 'flex';
1296
+ const pBtn = document.getElementById(`${mid}p`);
1297
+ const nBtn = document.getElementById(`${mid}n`);
1298
+ if (pBtn) pBtn.onclick = () => exReward(evt.strategy, evt.x_vec, 1, mid);
1299
+ if (nBtn) nBtn.onclick = () => exReward(evt.strategy, evt.x_vec, 0, mid);
1300
+ }
1301
+
1302
+ // Update elapsed + final widget.
1303
+ const elapsedEl = document.getElementById(`${mid}_elapsed`);
1304
+ if (elapsedEl) elapsedEl.textContent = (evt.elapsed !== null && evt.elapsed !== undefined) ? `${evt.elapsed}s` : '—';
1305
+
1306
+ if (evt.widget_html && String(evt.widget_html).trim()) {
1307
+ frame.classList.remove('ready');
1308
+ frame.style.height = `${Number(evt.widget_height || 420)}px`;
1309
+ frame.srcdoc = evt.widget_html;
1310
+ frame.addEventListener('load', () => frame.classList.add('ready'), { once: true });
1311
+ }
1312
+
1313
+ if (evt.auto_detected && evt.auto_r !== null) {
1314
+ addLog(evt.strategy, evt.auto_r, evt.auto_reason, 'auto');
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ // Baseline after adaptive stream.
1321
+ const dPlain = await plainJob;
1322
+ if (!isBaselineHidden() && dPlain) {
1323
+ if (dPlain.error) addErr('Baseline: ' + dPlain.error);
1324
+ else addAsstBubPlain(dPlain);
1325
+ }
1326
+
1327
+ if (!didFinalize) addErr('Adaptive: stream ended without final output.');
1328
+ } catch(e) {
1329
+ // Fallback to existing non-streaming endpoint.
1330
+ try { adaptiveEl.remove(); } catch(e2) {}
1331
+
1332
+ try {
1333
+ const d = await (await fetch('/api/chat', {
1334
+ method: 'POST',
1335
+ headers: { 'Content-Type': 'application/json' },
1336
+ body: JSON.stringify({ uid: UID, message: msg })
1337
+ })).json();
1338
+
1339
+ if (d.error) addErr('Adaptive: ' + d.error);
1340
+ else {
1341
+ if (!isBaselineHidden()) document.getElementById('tw_plain').style.display = 'none';
1342
+ document.getElementById('tw_adapt').style.display = 'none';
1343
+ addAsstBubAdaptive(d);
1344
+ setActive(d.strategy, d.instruction);
1345
+ setInference(d.scores, d.posterior, d.strategy, d);
1346
+ setFeatures(d.x_vec);
1347
+ updBars('ub', d.posterior, d.strategy);
1348
+ updBars('gb', d.global, null);
1349
+ updBars('bb', d.userb, null);
1350
+ document.getElementById('gn').textContent = d.global_n;
1351
+ if (d.auto_detected && d.auto_r !== null) addLog(d.strategy, d.auto_r, d.auto_reason, 'auto');
1352
+ }
1353
+ } catch(e2) {
1354
+ addErr('Connection error — is python app.py running?');
1355
+ }
1356
+ }
1357
+
1358
+ document.getElementById('send').disabled = false;
1359
+ document.getElementById('tw_plain').style.display = 'none';
1360
+ document.getElementById('tw_adapt').style.display = 'none';
1361
+ scrl();
1362
+ }
1363
+
1364
+
1365
+ function addUserBub(txt) {
1366
+ const d1 = mk('div','msg u');
1367
+ d1.innerHTML = `<div class="who">you</div><div class="bub">${esc(txt)}</div>`;
1368
+ document.getElementById('msgs_plain').appendChild(d1);
1369
+
1370
+ const d2 = mk('div','msg u');
1371
+ d2.innerHTML = `<div class="who">you</div><div class="bub">${esc(txt)}</div>`;
1372
+ document.getElementById('msgs_adapt').appendChild(d2);
1373
+ }
1374
+
1375
+
1376
+ function addAsstBubPlain(d) {
1377
+ const el = mk('div','msg a');
1378
+ el.innerHTML = `
1379
+ <div class="who">baseline</div>
1380
+ <div class="bub">${fmt(d.response, 'normal')}</div>
1381
+ <div class="meta">
1382
+ <span class="stag" style="background:rgba(255,255,255,.04);border:1px solid var(--bd);color:var(--dm)">Normal</span>
1383
+ <span class="el">${d.elapsed}s</span>
1384
+ </div>`;
1385
+ document.getElementById('msgs_plain').appendChild(el);
1386
+ }
1387
+
1388
+ function addAsstBubAdaptive(d) {
1389
+ const idx = SN.indexOf(d.strategy);
1390
+ const mid = 'M'+Date.now();
1391
+ const el = mk('div','msg a');
1392
+
1393
+ let ab = '';
1394
+ if (d.auto_detected && d.auto_r !== null) {
1395
+ const neg = d.auto_r < 0.5;
1396
+ ab = `<span class="autob ${neg?'neg':'pos'}">${neg?'⚡':'✦'} auto · ${d.auto_reason} → r=${d.auto_r.toFixed(2)}</span>`;
1397
+ }
1398
+
1399
+ let nb = '';
1400
+ if (d.force_explore) {
1401
+ const from = d.prev_strategy && SL[d.prev_strategy] ? SL[d.prev_strategy] : 'previous';
1402
+ const to = SL[d.strategy] || d.strategy;
1403
+ nb = `<span class="notice explore">Negative signal → exploring (${from} → ${to})</span>`;
1404
+ } else if (d.explicit) {
1405
+ const to = SL[d.strategy] || d.strategy;
1406
+ nb = `<span class="notice override">Explicit request → switching to ${to}</span>`;
1407
+ }
1408
+
1409
+ el.innerHTML = `
1410
+ <div class="who">engine</div>
1411
+ <div class="bub">
1412
+ ${fmt(d.response, d.strategy)}
1413
+ <div class="widget-mount" id="${mid}_wm"></div>
1414
+ </div>
1415
+ <div class="meta">
1416
+ <span class="stag v${idx}">${SL[d.strategy]}</span>
1417
+ <span class="el">${d.elapsed}s</span>
1418
+ ${nb}
1419
+ ${ab}
1420
+ <div class="rbs">
1421
+ <button class="rb" id="${mid}p" onclick="exReward('${d.strategy}',${JSON.stringify(d.x_vec)},1,'${mid}')">👍</button>
1422
+ <button class="rb neg" id="${mid}n" onclick="exReward('${d.strategy}',${JSON.stringify(d.x_vec)},0,'${mid}')">👎</button>
1423
+ </div>
1424
+ </div>`;
1425
+ document.getElementById('msgs_adapt').appendChild(el);
1426
+
1427
+ // Claude-like widget iframe: isolate generated UI + JS.
1428
+ if (d.widget_html && typeof d.widget_html === 'string' && d.widget_html.trim()) {
1429
+ const mount = document.getElementById(`${mid}_wm`);
1430
+ if (mount) {
1431
+ const skeleton = document.createElement('div');
1432
+ skeleton.className = 'widget-skeleton';
1433
+ skeleton.innerHTML = `
1434
+ <div class="line w60"></div>
1435
+ <div class="line w95"></div>
1436
+ <div class="line w80"></div>
1437
+ <div class="line w95"></div>
1438
+ `;
1439
+ mount.appendChild(skeleton);
1440
+
1441
+ const frame = document.createElement('iframe');
1442
+ frame.className = 'widget-frame';
1443
+ frame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
1444
+ frame.style.height = `${Number(d.widget_height || 420)}px`;
1445
+ frame.srcdoc = d.widget_html;
1446
+ frame.addEventListener('load', () => {
1447
+ frame.classList.add('ready');
1448
+ skeleton.remove();
1449
+ }, { once: true });
1450
+ setTimeout(() => {
1451
+ frame.classList.add('ready');
1452
+ skeleton.remove();
1453
+ }, 1200);
1454
+ mount.appendChild(frame);
1455
+ }
1456
+ }
1457
+ }
1458
+
1459
+
1460
+ function addErr(msg) {
1461
+ const d1 = mk('div','msg a');
1462
+ d1.innerHTML = `<div class="errbub">⚠ ${esc(msg)}</div>`;
1463
+ document.getElementById('msgs_plain').appendChild(d1);
1464
+
1465
+ const d2 = mk('div','msg a');
1466
+ d2.innerHTML = `<div class="errbub">⚠ ${esc(msg)}</div>`;
1467
+ document.getElementById('msgs_adapt').appendChild(d2);
1468
+ }
1469
+
1470
+
1471
+ async function exReward(strat, xv, reward, mid) {
1472
+ ['p','n'].forEach(s => document.getElementById(mid+s)?.classList.add('used'));
1473
+ const res = await fetch('/api/reward', {
1474
+ method:'POST', headers:{'Content-Type':'application/json'},
1475
+ body: JSON.stringify({uid:UID, strategy:strat, x_vec:xv, reward})
1476
+ });
1477
+ const d = await res.json();
1478
+ // Only now update the bars — explicit button press
1479
+ updBars('ub', d.posterior, strat);
1480
+ updBars('gb', d.global, null);
1481
+ updBars('bb', d.userb, null); // User B only moves here because global_n just incremented
1482
+ document.getElementById('gn').textContent = d.global_n;
1483
+ addLog(strat, reward, 'explicit button', 'manual');
1484
+ }
1485
+
1486
+ function setActive(s, desc) {
1487
+ const i = SN.indexOf(s);
1488
+ const n = document.getElementById('an');
1489
+ n.textContent = SL[s]; n.className = `an v${i}`;
1490
+ document.getElementById('ad').textContent = desc;
1491
+ }
1492
+
1493
+ function setInference(scores, posterior, sel, raw) {
1494
+ document.getElementById('ip').style.display = 'block';
1495
+ const sorted = Object.entries(scores).sort((a,b)=>b[1]-a[1]);
1496
+ const why = (()=>{
1497
+ if (raw?.locked) return `Locked to ${SL[raw.locked] || raw.locked}`;
1498
+ if (raw?.explicit) return `Explicit override → ${SL[sel] || sel}`;
1499
+ if (raw?.force_explore) return `Negative signal → exploring`;
1500
+ return `Thompson Sampling (sampled)`;
1501
+ })();
1502
+
1503
+ document.getElementById('it').innerHTML = `
1504
+ <div class="ir" style="margin-bottom:8px">
1505
+ <span class="in_">Decision</span>
1506
+ <span class="iv">${esc(why)}</span>
1507
+ </div>
1508
+ ` + sorted.map(([s,v])=>{
1509
+ const i = SN.indexOf(s);
1510
+ const p = posterior?.[s] || null;
1511
+ const mean = p ? (p.r*100) : null;
1512
+ const unc = p ? p.u : null;
1513
+ return `<div class="ir${s===sel?' sel':''}">
1514
+ <span class="in_ v${i}">${s===sel?'▶ ':''}${SL[s]}</span>
1515
+ <span class="iv v${i}">${(v*100).toFixed(1)}% <span class="in_" style="font-weight:400">(μ=${mean===null?'—':mean.toFixed(1)}%, u=${unc===null?'—':unc.toFixed(3)})</span></span>
1516
+ </div>`;
1517
+ }).join('');
1518
+ }
1519
+
1520
+ function setFeatures(xv) {
1521
+ document.getElementById('fp').style.display = 'block';
1522
+ document.getElementById('fg').innerHTML = xv.map((v,i)=>`
1523
+ <div class="fi"><div class="fk">${FN[i]||'x'+i}</div><div class="fv">${v.toFixed(3)}</div></div>`).join('');
1524
+ }
1525
+
1526
+ function addLog(strat, reward, reason, src) {
1527
+ const lg = document.getElementById('lg');
1528
+ lg.querySelector('p')?.remove();
1529
+ const i = SN.indexOf(strat);
1530
+ const neg = reward < 0.5;
1531
+ const d = mk('div','le');
1532
+ d.style.borderLeftColor = `var(--c${i})`;
1533
+ d.innerHTML = `
1534
+ <span class="v${i}">${SL[strat]}</span>
1535
+ <span class="lr" style="color:${neg?'var(--r)':'var(--g)'}">${neg?'👎':'👍'} ${reward.toFixed(2)}</span>
1536
+ <div class="ls">${reason} · ${src}</div>`;
1537
+ lg.prepend(d);
1538
+ }
1539
+
1540
+
1541
+ async function reset() {
1542
+ await fetch('/api/reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({uid:UID})});
1543
+ UID = 'u_' + Math.random().toString(36).slice(2,8);
1544
+ localStorage.setItem('ape_uid', UID);
1545
+
1546
+ document.getElementById('msgs_plain').innerHTML = `
1547
+ <div class="empty" id="emp_plain">
1548
+ <div class="ei">◻</div>
1549
+ <div class="et">Session reset</div>
1550
+ <div class="eb">Baseline history cleared. Adaptive posterior cleared. User B re-inherits the current global prior.</div>
1551
+ </div>`;
1552
+
1553
+ document.getElementById('msgs_adapt').innerHTML = `
1554
+ <div class="empty" id="emp_adapt">
1555
+ <div class="ei">⬡</div>
1556
+ <div class="et">Session reset</div>
1557
+ <div class="eb">Adaptive history cleared. Preferences prompt will show again.</div>
1558
+ </div>`;
1559
+
1560
+ document.getElementById('tw_plain').style.display = 'none';
1561
+ document.getElementById('tw_adapt').style.display = 'none';
1562
+ document.getElementById('an').textContent = '—';
1563
+ document.getElementById('an').className = 'an v0';
1564
+ document.getElementById('ad').textContent = 'Waiting for first message…';
1565
+ document.getElementById('ip').style.display = 'none';
1566
+ document.getElementById('fp').style.display = 'none';
1567
+ document.getElementById('lg').innerHTML = '<p style="font-size:10px;color:var(--dmr);text-align:center;padding:16px">No interactions yet</p>';
1568
+ // reset bars visually (optional)
1569
+ updBars('ub', {}, null); updBars('gb', {}, null); updBars('bb', {}, null);
1570
+
1571
+ showPrefModal();
1572
+ }
1573
+
1574
+ // ── One-time boot check
1575
+ async function boot() {
1576
+ // Restore UI layout toggles
1577
+ try {
1578
+ if (localStorage.getItem('hideBaseline') === '1') {
1579
+ document.body.classList.add('hideBaseline');
1580
+ }
1581
+ } catch(e) {}
1582
+ syncBaselineToggleUI();
1583
+
1584
+ try {
1585
+ const r = await fetch('/api/state?uid='+UID);
1586
+ if (r.ok) {
1587
+ document.getElementById('dot').className = 'dot live';
1588
+ document.getElementById('olbl').textContent = 'server live';
1589
+ const d = await r.json();
1590
+ updBars('ub', d.posterior, null);
1591
+ updBars('gb', d.global, null);
1592
+ updBars('bb', d.userb, null);
1593
+ document.getElementById('gn').textContent = d.global_n || 0;
1594
+ document.getElementById('nu').textContent = d.n_users || 1;
1595
+
1596
+ // Show preference modal if this user has no history yet
1597
+ if (d.msg_count === 0) {
1598
+ showPrefModal();
1599
+ }
1600
+ }
1601
+ } catch {
1602
+ document.getElementById('dot').className = 'dot err';
1603
+ document.getElementById('olbl').textContent = 'offline';
1604
+ }
1605
+ }
1606
+
1607
+ // helpers
1608
+ function mk(t,c){const d=document.createElement(t);d.className=c;return d}
1609
+
1610
+ function safe(s){
1611
+ return String(s ?? '')
1612
+ .replace(/&/g,'&amp;')
1613
+ .replace(/</g,'&lt;')
1614
+ .replace(/>/g,'&gt;');
1615
+ }
1616
+ function esc(s){ return safe(s); }
1617
+
1618
+ function tryParseTableJSON(raw){
1619
+ try{
1620
+ const obj = JSON.parse(String(raw).trim());
1621
+ if (Array.isArray(obj.columns) && Array.isArray(obj.rows)) return obj;
1622
+ }catch(e){}
1623
+ return null;
1624
+ }
1625
+
1626
+ function splitMdRow(line){
1627
+ let txt = String(line || '').trim();
1628
+ if (!txt) return [];
1629
+ if (txt.startsWith('|')) txt = txt.slice(1);
1630
+ if (txt.endsWith('|')) txt = txt.slice(0, -1);
1631
+ return txt.split('|').map(c => c.trim());
1632
+ }
1633
+
1634
+ function isMdSepLine(line){
1635
+ const cells = splitMdRow(line);
1636
+ if (!cells.length) return false;
1637
+ return cells.every(c => /^:?-{3,}:?$/.test(c));
1638
+ }
1639
+
1640
+ function tryParseMarkdownTable(raw){
1641
+ const lines = String(raw || '')
1642
+ .split(/\r?\n/)
1643
+ .map(s => s.trim())
1644
+ .filter(Boolean);
1645
+ if (lines.length < 2) return null;
1646
+
1647
+ for (let i = 0; i < lines.length - 1; i++) {
1648
+ const headerLine = lines[i];
1649
+ const sepLine = lines[i + 1];
1650
+ if (!headerLine.includes('|') || !isMdSepLine(sepLine)) continue;
1651
+
1652
+ const columns = splitMdRow(headerLine);
1653
+ if (!columns.length) continue;
1654
+
1655
+ const rows = [];
1656
+ for (let j = i + 2; j < lines.length; j++) {
1657
+ const ln = lines[j];
1658
+ if (!ln.includes('|')) break;
1659
+ const row = splitMdRow(ln);
1660
+ if (!row.length) break;
1661
+ while (row.length < columns.length) row.push('');
1662
+ rows.push(row.slice(0, columns.length));
1663
+ }
1664
+ if (rows.length) return { columns, rows };
1665
+ }
1666
+ return null;
1667
+ }
1668
+
1669
+ function tableToHtml(obj){
1670
+ const cols = obj.columns || [];
1671
+ const rows = obj.rows || [];
1672
+ const thead = `<thead><tr>${cols.map(c => `<th>${safe(c)}</th>`).join('')}</tr></thead>`;
1673
+ const tbody = `<tbody>${rows.map(r => `<tr>${r.map(v => `<td>${safe(v)}</td>`).join('')}</tr>`).join('')}</tbody>`;
1674
+ return `<table>${thead}${tbody}</table>`;
1675
+ }
1676
+
1677
+ function tryParseVizJSON(raw){
1678
+ try{
1679
+ const obj = JSON.parse(String(raw).trim());
1680
+ if (obj && typeof obj === 'object' && Array.isArray(obj.labels) && Array.isArray(obj.values)) return obj;
1681
+ }catch(e){}
1682
+ return null;
1683
+ }
1684
+
1685
+ function renderBarChart(obj, horizontal=false){
1686
+ const labels = obj.labels || [];
1687
+ const values = (obj.values || []).map(v => Number(v ?? 0));
1688
+ const maxVal = Math.max(...values, 1);
1689
+
1690
+ if (horizontal) {
1691
+ return `
1692
+ <div class="viz-wrap">
1693
+ ${obj.title ? `<div style="font-weight:700;margin-bottom:10px;">${safe(obj.title)}</div>` : ''}
1694
+ ${labels.map((label, i) => {
1695
+ const v = Number(values[i] ?? 0);
1696
+ const pct = Math.max(4, (v / maxVal) * 100);
1697
+ return `
1698
+ <div style="margin:10px 0;">
1699
+ <div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px;">
1700
+ <span>${safe(label)}</span>
1701
+ <span>${safe(v)}</span>
1702
+ </div>
1703
+ <div style="height:10px;background:rgba(15,23,42,.08);border-radius:999px;overflow:hidden;">
1704
+ <div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#7c6cff,#40d9ff);border-radius:999px;"></div>
1705
+ </div>
1706
+ </div>
1707
+ `;
1708
+ }).join('')}
1709
+ </div>
1710
+ `;
1711
+ }
1712
+
1713
+ const bars = labels.map((label, i) => {
1714
+ const v = Number(values[i] ?? 0);
1715
+ const h = Math.max(12, (v / maxVal) * 140);
1716
+ return `
1717
+ <div style="display:flex;flex-direction:column;align-items:center;gap:6px;flex:1;">
1718
+ <div style="font-size:11px;">${safe(v)}</div>
1719
+ <div style="width:32px;height:${h}px;background:linear-gradient(180deg,#7c6cff,#40d9ff);border-radius:10px 10px 0 0;"></div>
1720
+ <div style="font-size:11px;text-align:center;">${safe(label)}</div>
1721
+ </div>
1722
+ `;
1723
+ }).join('');
1724
+
1725
+ return `
1726
+ <div class="viz-wrap">
1727
+ ${obj.title ? `<div style="font-weight:700;margin-bottom:10px;">${safe(obj.title)}</div>` : ''}
1728
+ <div style="display:flex;align-items:flex-end;gap:12px;min-height:180px;padding-top:10px;">
1729
+ ${bars}
1730
+ </div>
1731
+ </div>
1732
+ `;
1733
+ }
1734
+
1735
+ function renderPieChart(obj, donut=false){
1736
+ const labels = obj.labels || [];
1737
+ const values = (obj.values || []).map(v => Number(v || 0));
1738
+ const total = values.reduce((a,b) => a+b, 0) || 1;
1739
+
1740
+ let angle = 0;
1741
+ const colors = ['#7c6cff','#40d9ff','#00c896','#ffb84d','#ff6b81','#9b8cff'];
1742
+
1743
+ const segments = values.map((v, i) => {
1744
+ const pct = v / total;
1745
+ const nextAngle = angle + pct * 360;
1746
+ const color = colors[i % colors.length];
1747
+ const seg = `${color} ${angle}deg ${nextAngle}deg`;
1748
+ angle = nextAngle;
1749
+ return seg;
1750
+ }).join(', ');
1751
+
1752
+ return `
1753
+ <div class="viz-wrap">
1754
+ ${obj.title ? `<div style="font-weight:700;margin-bottom:12px;">${safe(obj.title)}</div>` : ''}
1755
+ <div style="display:flex;gap:18px;align-items:center;flex-wrap:wrap;">
1756
+ <div style="
1757
+ width:180px;
1758
+ height:180px;
1759
+ border-radius:50%;
1760
+ background:conic-gradient(${segments});
1761
+ position:relative;
1762
+ flex:0 0 auto;
1763
+ ">
1764
+ ${donut ? `
1765
+ <div style="
1766
+ position:absolute;
1767
+ inset:38px;
1768
+ border-radius:50%;
1769
+ background:#ffffff;
1770
+ border:1px solid rgba(15,23,42,.08);
1771
+ "></div>
1772
+ ` : ''}
1773
+ </div>
1774
+ <div style="flex:1;min-width:180px;">
1775
+ ${labels.map((label, i) => {
1776
+ const v = values[i] || 0;
1777
+ const pct = ((v / total) * 100).toFixed(1);
1778
+ const color = colors[i % colors.length];
1779
+ return `
1780
+ <div style="display:flex;align-items:center;justify-content:space-between;margin:8px 0;font-size:12px;">
1781
+ <div style="display:flex;align-items:center;gap:8px;">
1782
+ <span style="width:10px;height:10px;border-radius:50%;background:${color};display:inline-block;"></span>
1783
+ <span>${safe(label)}</span>
1784
+ </div>
1785
+ <span>${safe(v)} (${pct}%)</span>
1786
+ </div>
1787
+ `;
1788
+ }).join('')}
1789
+ </div>
1790
+ </div>
1791
+ </div>
1792
+ `;
1793
+ }
1794
+
1795
+ function renderLineChart(obj){
1796
+ const labels = obj.labels || [];
1797
+ const values = (obj.values || []).map(v => Number(v || 0));
1798
+ const maxVal = Math.max(...values, 1);
1799
+ const minVal = Math.min(...values, 0);
1800
+
1801
+ const points = values.map((v, i) => {
1802
+ const x = (i / Math.max(values.length - 1, 1)) * 280;
1803
+ const y = 140 - ((v - minVal) / Math.max(maxVal - minVal, 1)) * 120;
1804
+ return `${x},${y}`;
1805
+ }).join(' ');
1806
+
1807
+ return `
1808
+ <div class="viz-wrap">
1809
+ ${obj.title ? `<div style="font-weight:700;margin-bottom:10px;">${safe(obj.title)}</div>` : ''}
1810
+ <svg width="100%" viewBox="0 0 300 170" style="max-width:420px;">
1811
+ <polyline fill="none" stroke="#7c6cff" stroke-width="3" points="${points}" />
1812
+ ${values.map((v, i) => {
1813
+ const x = (i / Math.max(values.length - 1, 1)) * 280;
1814
+ const y = 140 - ((v - minVal) / Math.max(maxVal - minVal, 1)) * 120;
1815
+ return `<circle cx="${x}" cy="${y}" r="4" fill="#40d9ff"></circle>`;
1816
+ }).join('')}
1817
+ </svg>
1818
+ <div style="display:flex;justify-content:space-between;gap:8px;font-size:11px;flex-wrap:wrap;margin-top:8px;">
1819
+ ${labels.map(l => `<span>${safe(l)}</span>`).join('')}
1820
+ </div>
1821
+ </div>
1822
+ `;
1823
+ }
1824
+
1825
+ function vizToHtml(obj){
1826
+ const type = String(obj.type || 'bar').toLowerCase();
1827
+ if (type === 'pie') return renderPieChart(obj, false);
1828
+ if (type === 'donut') return renderPieChart(obj, true);
1829
+ if (type === 'line') return renderLineChart(obj);
1830
+ if (type === 'horizontal_bar') return renderBarChart(obj, true);
1831
+ return renderBarChart(obj, false);
1832
+ }
1833
+
1834
+ function renderCodeBlock(raw){
1835
+ const m = String(raw).match(/```[a-zA-Z0-9_-]*\n?([\s\S]*?)```/);
1836
+ if (m) return `<pre><code>${safe(m[1].trim())}</code></pre>`;
1837
+ return `<pre><code>${safe(raw)}</code></pre>`;
1838
+ }
1839
+
1840
+ function fmt(s, strategy=''){
1841
+ const raw = String(s ?? '').trim();
1842
+
1843
+ if (strategy === 'comparison_table') {
1844
+ const parsed = tryParseTableJSON(raw) || tryParseMarkdownTable(raw);
1845
+ if (parsed) return tableToHtml(parsed);
1846
+ }
1847
+
1848
+ if (strategy === 'visualization') {
1849
+ const parsed = tryParseVizJSON(raw);
1850
+ if (parsed) return vizToHtml(parsed);
1851
+ return renderCodeBlock(raw);
1852
+ }
1853
+
1854
+ return safe(raw)
1855
+ .replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
1856
+ .replace(/\n\n/g,'<br><br>')
1857
+ .replace(/\n/g,'<br>');
1858
+ }
1859
+
1860
+ function scrl(){
1861
+ const a = document.getElementById('msgs_plain');
1862
+ const b = document.getElementById('msgs_adapt');
1863
+ if (a) a.scrollTop = a.scrollHeight;
1864
+ if (b) b.scrollTop = b.scrollHeight;
1865
+ }
1866
+
1867
+ function onK(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}}
1868
+ function rsz(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,110)+'px'}
1869
+
1870
+ // Bridge: widget iframe -> parent chat input.
1871
+ window.addEventListener('message', (ev) => {
1872
+ const data = ev.data || {};
1873
+ if (data && data.type === "widget:height") {
1874
+ const nextH = Number(data.value || 0);
1875
+ if (Number.isFinite(nextH) && nextH > 0) {
1876
+ const frames = document.querySelectorAll('iframe.widget-frame');
1877
+ for (const frame of frames) {
1878
+ if (frame.contentWindow === ev.source) {
1879
+ const clamped = Math.max(260, Math.min(Math.round(nextH) + 12, 1400));
1880
+ frame.style.height = `${clamped}px`;
1881
+ break;
1882
+ }
1883
+ }
1884
+ }
1885
+ return;
1886
+ }
1887
+
1888
+ let text = "";
1889
+ // Only accept explicit widget/sendPrompt messages; ignore raw strings
1890
+ // such as iFrameSizer noise from hosting environments like HF Spaces.
1891
+ if (data.type === "widget:sendPrompt" && typeof data.text === "string") text = data.text;
1892
+ else if (data.type === "streamlit:setComponentValue" && typeof data.value === "string") text = data.value;
1893
+ text = String(text || "").trim();
1894
+ if (!text) return;
1895
+
1896
+ const inp = document.getElementById('inp');
1897
+ if (!inp) return;
1898
+ inp.value = text;
1899
+ rsz(inp);
1900
+ send();
1901
+ });
1902
+
1903
+ boot();
1904
+
1905
+ // ── Tab switching
1906
+ function switchTab(tab) {
1907
+ const fw = document.getElementById('fw-page');
1908
+ const mainEls = [document.querySelector('.chat'), document.querySelector('.sa'), document.querySelector('.sb')];
1909
+ const sidebarBtn = document.getElementById('toggle-sidebars-btn');
1910
+ const dotArea = document.getElementById('dot');
1911
+
1912
+ document.getElementById('tab-main').classList.toggle('active', tab === 'main');
1913
+ document.getElementById('tab-fw').classList.toggle('active', tab === 'fw');
1914
+
1915
+ if (tab === 'fw') {
1916
+ fw.classList.add('active');
1917
+ mainEls.forEach(el => { if (el) el.style.display = 'none'; });
1918
+ if (sidebarBtn) sidebarBtn.style.display = 'none';
1919
+ } else {
1920
+ fw.classList.remove('active');
1921
+ mainEls.forEach(el => { if (el) el.style.removeProperty('display'); });
1922
+ if (sidebarBtn) sidebarBtn.style.display = '';
1923
+ // restore hideSidebars state
1924
+ if (document.body.classList.contains('hideSidebars')) {
1925
+ mainEls.forEach(el => {
1926
+ if (el && (el.classList.contains('sa') || el.classList.contains('sb'))) el.style.display = 'none';
1927
+ });
1928
+ }
1929
+ }
1930
+ }
1931
+
1932
+ </script>
1933
+
1934
+ <!-- FUTURE WORK PAGE -->
1935
+ <div id="fw-page">
1936
+ <div class="fw-inner">
1937
+ <div class="fw-eyebrow">Adaptive Presentation Engine</div>
1938
+ <div class="fw-title">Cognitive Mental Model</div>
1939
+ <div class="fw-subtitle">A living portrait — not a static persona — constructed through revealed preferences and continuously refined by Bayesian inference.</div>
1940
+
1941
+ <!-- Profile card -->
1942
+ <div class="fw-profile">
1943
+ <div class="fw-profile-left">
1944
+ <div class="fw-profile-name">Margaret Chen</div>
1945
+ <div class="fw-profile-sub">VG-0847291 · 4 months · 47 interactions</div>
1946
+ </div>
1947
+ <div class="fw-profile-stats">
1948
+ <div class="fw-stat">
1949
+ <div class="fw-stat-val blue">75%</div>
1950
+ <div class="fw-stat-lbl">Model Confidence</div>
1951
+ </div>
1952
+ <div class="fw-stat">
1953
+ <div class="fw-stat-val green">5</div>
1954
+ <div class="fw-stat-lbl">Active Facets</div>
1955
+ </div>
1956
+ <div class="fw-stat">
1957
+ <div class="fw-stat-val orange">3</div>
1958
+ <div class="fw-stat-lbl">High Fidelity</div>
1959
+ </div>
1960
+ </div>
1961
+ </div>
1962
+
1963
+ <!-- Portrait summary -->
1964
+ <div class="fw-summary">
1965
+ <div class="fw-summary-hdr">Portrait Summary — Earned Through 47 Interactions</div>
1966
+ <div class="fw-summary-text">
1967
+ Margaret processes financial decisions through <span class="hi-blue">causal narratives</span> when stakes involve action
1968
+ (rebalancing, volatility response) but switches to <span class="hi-green">structured elimination tools</span> when exploring options.
1969
+ She seeks <span class="hi-orange">historical anchoring</span> during market stress rather than forward projections. Tax-related and
1970
+ retirement planning facets are still developing — the system is actively exploring presentation strategies
1971
+ in these contexts. Her cognitive profile suggests a mind that wants to understand consequences before
1972
+ acting, but prefers systematic reduction when choosing.
1973
+ </div>
1974
+ </div>
1975
+
1976
+ <!-- Cognitive facets -->
1977
+ <div class="fw-facets-hdr">
1978
+ <div class="fw-facets-title">Cognitive Facets</div>
1979
+ <div class="fw-facets-sort">sorted by confidence</div>
1980
+ </div>
1981
+
1982
+ <!-- Facet 1: Rebalancing Decisions -->
1983
+ <div class="fw-facet blue-border" onclick="toggleFacet(this)">
1984
+ <div class="fw-facet-top">
1985
+ <div class="fw-ring-wrap">
1986
+ <svg viewBox="0 0 52 52"><circle cx="26" cy="26" r="22" fill="none" stroke="#1a2d42" stroke-width="4"/><circle cx="26" cy="26" r="22" fill="none" stroke="#4a9eff" stroke-width="4" stroke-dasharray="138.2 138.2" stroke-dashoffset="16.6" stroke-linecap="round"/></svg>
1987
+ <div class="fw-ring-score">88</div>
1988
+ </div>
1989
+ <div class="fw-facet-info">
1990
+ <div class="fw-facet-name">Rebalancing Decisions</div>
1991
+ <div class="fw-facet-sub">14 interactions <span class="fw-fidelity high">High</span></div>
1992
+ </div>
1993
+ <div class="fw-facet-right">
1994
+ <div class="fw-strategy-name">Scenario Narrative</div>
1995
+ <div class="fw-strategy-mu">μ = 0.85</div>
1996
+ </div>
1997
+ <span class="fw-chevron">▾</span>
1998
+ </div>
1999
+ <div class="fw-facet-body">
2000
+ <div class="fw-beta-row">
2001
+ <div class="fw-beta-cell">
2002
+ <div class="fw-beta-label" style="color:#4a9eff">Leading: Scenario Narrative <span class="param">BETA(11, 2)</span></div>
2003
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><defs><linearGradient id="g1" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#4a9eff" stop-opacity=".4"/><stop offset="100%" stop-color="#4a9eff" stop-opacity="0"/></linearGradient></defs><path d="M0,75 C20,75 30,72 50,60 C70,48 80,20 100,8 C110,3 120,3 130,8 C150,18 170,60 190,72 C205,78 215,76 220,75" fill="url(#g1)" stroke="#4a9eff" stroke-width="2"/></svg>
2004
+ </div>
2005
+ <div class="fw-beta-cell">
2006
+ <div class="fw-beta-label" style="color:#3a5a72">Runner-up: Risk-First Table <span class="param" style="color:#2a4a60">BETA(3, 8)</span></div>
2007
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><path d="M0,75 C20,75 40,73 70,65 C90,58 100,45 110,40 C120,35 130,38 150,52 C170,65 190,73 220,75" fill="none" stroke="#2a4a60" stroke-width="2"/></svg>
2008
+ </div>
2009
+ </div>
2010
+ <div class="fw-insight" style="background:rgba(74,158,255,.06);border:1px solid rgba(74,158,255,.12)">
2011
+ <div class="fw-insight-hdr" style="color:#4a9eff">Cognitive Insight</div>
2012
+ <div class="fw-insight-text">Responds best to scenario-based framing that shows consequences. Engagement drops 40% when presented with abstract risk tables. Prefers "what happens if" over "here are the numbers."</div>
2013
+ </div>
2014
+ <div class="fw-evidence">Completed portfolio rebalance after scenario walkthrough on 3 separate occasions (Feb 10, Feb 18, Mar 2)</div>
2015
+ </div>
2016
+ </div>
2017
+
2018
+ <!-- Facet 2: Fund Exploration -->
2019
+ <div class="fw-facet green-border open" onclick="toggleFacet(this)">
2020
+ <div class="fw-facet-top">
2021
+ <div class="fw-ring-wrap">
2022
+ <svg viewBox="0 0 52 52"><circle cx="26" cy="26" r="22" fill="none" stroke="#1a3a2a" stroke-width="4"/><circle cx="26" cy="26" r="22" fill="none" stroke="#2ecc8c" stroke-width="4" stroke-dasharray="138.2 138.2" stroke-dashoffset="28.5" stroke-linecap="round"/></svg>
2023
+ <div class="fw-ring-score">79</div>
2024
+ </div>
2025
+ <div class="fw-facet-info">
2026
+ <div class="fw-facet-name">Fund Exploration</div>
2027
+ <div class="fw-facet-sub">11 interactions <span class="fw-fidelity moderate">Moderate</span></div>
2028
+ </div>
2029
+ <div class="fw-facet-right">
2030
+ <div class="fw-strategy-name">Elimination Matrix</div>
2031
+ <div class="fw-strategy-mu">μ = 0.80</div>
2032
+ </div>
2033
+ <span class="fw-chevron">▾</span>
2034
+ </div>
2035
+ <div class="fw-facet-body">
2036
+ <div class="fw-beta-row">
2037
+ <div class="fw-beta-cell">
2038
+ <div class="fw-beta-label" style="color:#2ecc8c">Leading: Elimination Matrix <span class="param">BETA(8, 2)</span></div>
2039
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><defs><linearGradient id="g2" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#2ecc8c" stop-opacity=".35"/><stop offset="100%" stop-color="#2ecc8c" stop-opacity="0"/></linearGradient></defs><path d="M0,75 C20,75 35,70 55,55 C75,40 85,14 105,7 C115,3 125,5 140,14 C160,28 180,62 200,72 C210,77 216,76 220,75" fill="url(#g2)" stroke="#2ecc8c" stroke-width="2"/></svg>
2040
+ </div>
2041
+ <div class="fw-beta-cell">
2042
+ <div class="fw-beta-label" style="color:#3a5a72">Runner-up: Comparison Table <span class="param" style="color:#2a4a60">BETA(4, 5)</span></div>
2043
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><path d="M0,75 C20,75 40,72 65,62 C80,56 95,44 110,38 C125,32 140,38 158,50 C175,62 195,72 220,75" fill="none" stroke="#2a4a60" stroke-width="2"/></svg>
2044
+ </div>
2045
+ </div>
2046
+ <div class="fw-insight" style="background:rgba(46,204,140,.06);border:1px solid rgba(46,204,140,.12)">
2047
+ <div class="fw-insight-hdr" style="color:#2ecc8c">Cognitive Insight</div>
2048
+ <div class="fw-insight-text">Engages deeply with structured narrowing tools. Spends 3x longer on elimination workflows than open-ended comparisons. Prefers to reduce options before evaluating.</div>
2049
+ </div>
2050
+ <div class="fw-evidence">Completed full elimination flow for bond fund selection (Feb 28)</div>
2051
+ </div>
2052
+ </div>
2053
+
2054
+ <!-- Facet 3: Market Volatility Response -->
2055
+ <div class="fw-facet orange-border" onclick="toggleFacet(this)">
2056
+ <div class="fw-facet-top">
2057
+ <div class="fw-ring-wrap">
2058
+ <svg viewBox="0 0 52 52"><circle cx="26" cy="26" r="22" fill="none" stroke="#3a2a10" stroke-width="4"/><circle cx="26" cy="26" r="22" fill="none" stroke="#f5a623" stroke-width="4" stroke-dasharray="138.2 138.2" stroke-dashoffset="41.5" stroke-linecap="round"/></svg>
2059
+ <div class="fw-ring-score">72</div>
2060
+ </div>
2061
+ <div class="fw-facet-info">
2062
+ <div class="fw-facet-name">Market Volatility Response</div>
2063
+ <div class="fw-facet-sub">8 interactions <span class="fw-fidelity moderate">Moderate</span></div>
2064
+ </div>
2065
+ <div class="fw-facet-right">
2066
+ <div class="fw-strategy-name">Historical Precedent</div>
2067
+ <div class="fw-strategy-mu">μ = 0.75</div>
2068
+ </div>
2069
+ <span class="fw-chevron">▾</span>
2070
+ </div>
2071
+ <div class="fw-facet-body">
2072
+ <div class="fw-beta-row">
2073
+ <div class="fw-beta-cell">
2074
+ <div class="fw-beta-label" style="color:#f5a623">Leading: Historical Precedent <span class="param">BETA(6, 2)</span></div>
2075
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><defs><linearGradient id="g3" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#f5a623" stop-opacity=".35"/><stop offset="100%" stop-color="#f5a623" stop-opacity="0"/></linearGradient></defs><path d="M0,75 C15,75 30,72 55,58 C75,46 88,22 108,12 C118,7 128,9 142,18 C162,32 182,64 205,73 C213,77 218,76 220,75" fill="url(#g3)" stroke="#f5a623" stroke-width="2"/></svg>
2076
+ </div>
2077
+ <div class="fw-beta-cell">
2078
+ <div class="fw-beta-label" style="color:#3a5a72">Runner-up: Scenario Narrative <span class="param" style="color:#2a4a60">BETA(3, 3)</span></div>
2079
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><path d="M0,75 C15,75 30,72 55,60 C75,50 95,38 110,35 C125,32 140,38 160,52 C178,64 200,73 220,75" fill="none" stroke="#2a4a60" stroke-width="2"/></svg>
2080
+ </div>
2081
+ </div>
2082
+ <div class="fw-insight" style="background:rgba(245,166,35,.06);border:1px solid rgba(245,166,35,.12)">
2083
+ <div class="fw-insight-hdr" style="color:#f5a623">Cognitive Insight</div>
2084
+ <div class="fw-insight-text">During volatility, seeks anchoring in past outcomes rather than forward projections. Responds to 'markets recovered in X months after similar events' framing. Narrative works here too but historical data is more calming.</div>
2085
+ </div>
2086
+ <div class="fw-evidence">Engaged 4.5 min with 2022 drawdown comparison during Feb correction (Feb 22)</div>
2087
+ </div>
2088
+ </div>
2089
+
2090
+ <!-- Facet 4: Tax-Related Decisions -->
2091
+ <div class="fw-facet red-border" onclick="toggleFacet(this)">
2092
+ <div class="fw-facet-top">
2093
+ <div class="fw-ring-wrap">
2094
+ <svg viewBox="0 0 52 52"><circle cx="26" cy="26" r="22" fill="none" stroke="#3a1a1a" stroke-width="4"/><circle cx="26" cy="26" r="22" fill="none" stroke="#e05555" stroke-width="4" stroke-dasharray="138.2 138.2" stroke-dashoffset="82.9" stroke-linecap="round"/></svg>
2095
+ <div class="fw-ring-score">54</div>
2096
+ </div>
2097
+ <div class="fw-facet-info">
2098
+ <div class="fw-facet-name">Tax-Related Decisions</div>
2099
+ <div class="fw-facet-sub">5 interactions <span class="fw-fidelity emerging">Emerging</span></div>
2100
+ </div>
2101
+ <div class="fw-facet-right">
2102
+ <div class="fw-strategy-name">Step-by-Step Simplification</div>
2103
+ <div class="fw-strategy-mu">μ = 0.75</div>
2104
+ </div>
2105
+ <span class="fw-chevron">▾</span>
2106
+ </div>
2107
+ <div class="fw-facet-body">
2108
+ <div class="fw-beta-row">
2109
+ <div class="fw-beta-cell">
2110
+ <div class="fw-beta-label" style="color:#e05555">Leading: Step-by-Step <span class="param">BETA(3, 1)</span></div>
2111
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><defs><linearGradient id="g4" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#e05555" stop-opacity=".3"/><stop offset="100%" stop-color="#e05555" stop-opacity="0"/></linearGradient></defs><path d="M0,75 C20,75 40,74 65,70 C85,66 100,56 120,42 C140,28 165,18 190,14 C205,12 215,12 220,12" fill="url(#g4)" stroke="#e05555" stroke-width="2"/></svg>
2112
+ </div>
2113
+ <div class="fw-beta-cell">
2114
+ <div class="fw-beta-label" style="color:#3a5a72">Runner-up: Binary Decision Tree <span class="param" style="color:#2a4a60">BETA(2, 2)</span></div>
2115
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><path d="M0,75 C15,74 30,72 55,64 C80,55 100,38 110,32 C120,26 140,32 160,48 C180,62 200,73 220,75" fill="none" stroke="#2a4a60" stroke-width="2"/></svg>
2116
+ </div>
2117
+ </div>
2118
+ <div class="fw-insight" style="background:rgba(224,85,85,.06);border:1px solid rgba(224,85,85,.12)">
2119
+ <div class="fw-insight-hdr" style="color:#e05555">Cognitive Insight</div>
2120
+ <div class="fw-insight-text">Early signal suggests preference for sequential, simplified explanations over decision trees. Asks clarifying follow-ups when presented with branching logic. Confidence still developing — 5 more interactions needed for reliable convergence.</div>
2121
+ </div>
2122
+ <div class="fw-evidence">Asked 3 follow-up questions after tax-loss harvesting explanation (Feb 15)</div>
2123
+ </div>
2124
+ </div>
2125
+
2126
+ <!-- Facet 5: Retirement Planning -->
2127
+ <div class="fw-facet purple-border" onclick="toggleFacet(this)">
2128
+ <div class="fw-facet-top">
2129
+ <div class="fw-ring-wrap">
2130
+ <svg viewBox="0 0 52 52"><circle cx="26" cy="26" r="22" fill="none" stroke="#231840" stroke-width="4"/><circle cx="26" cy="26" r="22" fill="none" stroke="#9a5adc" stroke-width="4" stroke-dasharray="138.2 138.2" stroke-dashoffset="96.7" stroke-linecap="round"/></svg>
2131
+ <div class="fw-ring-score" style="font-size:13px">38</div>
2132
+ </div>
2133
+ <div class="fw-facet-info">
2134
+ <div class="fw-facet-name">Retirement Planning</div>
2135
+ <div class="fw-facet-sub">3 interactions <span class="fw-fidelity exploring">Exploring</span></div>
2136
+ </div>
2137
+ <div class="fw-facet-right">
2138
+ <div class="fw-strategy-name">Uncertain</div>
2139
+ <div class="fw-strategy-mu">μ = 0.67</div>
2140
+ </div>
2141
+ <span class="fw-chevron">▾</span>
2142
+ </div>
2143
+ <div class="fw-facet-body">
2144
+ <div class="fw-beta-row">
2145
+ <div class="fw-beta-cell">
2146
+ <div class="fw-beta-label" style="color:#9a5adc">Leading: Goal Visualizer <span class="param">BETA(2, 1)</span></div>
2147
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><defs><linearGradient id="g5" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#9a5adc" stop-opacity=".28"/><stop offset="100%" stop-color="#9a5adc" stop-opacity="0"/></linearGradient></defs><path d="M0,75 C30,75 55,74 85,68 C110,62 130,48 150,36 C165,26 182,18 200,14 C210,11 216,11 220,11" fill="url(#g5)" stroke="#9a5adc" stroke-width="2"/></svg>
2148
+ </div>
2149
+ <div class="fw-beta-cell">
2150
+ <div class="fw-beta-label" style="color:#3a5a72">Runner-up: Projection Table <span class="param" style="color:#2a4a60">BETA(2, 2)</span></div>
2151
+ <svg class="fw-beta-svg" viewBox="0 0 220 80"><path d="M0,75 C15,75 30,73 55,65 C75,57 95,42 110,37 C125,32 140,38 160,52 C178,64 200,73 220,75" fill="none" stroke="#2a4a60" stroke-width="2"/></svg>
2152
+ </div>
2153
+ </div>
2154
+ <div class="fw-insight" style="background:rgba(154,90,220,.06);border:1px solid rgba(154,90,220,.12)">
2155
+ <div class="fw-insight-hdr" style="color:#9a5adc">Cognitive Insight</div>
2156
+ <div class="fw-insight-text">Insufficient data to form a confident facet. Three early interactions suggest possible preference for visual goal framing, but high uncertainty remains. Engine is in active exploration mode for this context.</div>
2157
+ </div>
2158
+ <div class="fw-evidence">This model is a posterior, not a profile. High-fidelity facets reflect strong convergence across multiple interactions.</div>
2159
+ </div>
2160
+ </div>
2161
+
2162
+ <div class="fw-footer-note">
2163
+ This model is a posterior, not a profile. High-fidelity facets (rebalancing, fund exploration, volatility) reflect strong convergence — the engine has seen enough signal to make reliable predictions. Emerging facets are still being shaped. Every interaction updates the weights; the portrait is never frozen.
2164
+ </div>
2165
+ </div>
2166
+ </div>
2167
+
2168
+ <script>
2169
+ function toggleFacet(el) {
2170
+ el.classList.toggle('open');
2171
+ }
2172
+ </script>
2173
+
2174
+ </body>
2175
+ </html>
anupa/requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ numpy
2
+ python-dotenv
3
+ anthropic