Dr-P commited on
Commit
bf5175d
Β·
verified Β·
1 Parent(s): 5f014c7

Upload 2 files

Browse files
Files changed (2) hide show
  1. app (8).py +1652 -0
  2. requirements (4).txt +6 -0
app (8).py ADDED
@@ -0,0 +1,1652 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import threading
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ import gradio as gr
13
+ import pandas as pd
14
+ from sklearn.feature_extraction.text import TfidfVectorizer
15
+ from sklearn.metrics.pairwise import cosine_similarity
16
+ from sklearn.neighbors import NearestNeighbors
17
+ import numpy as np
18
+
19
+
20
+ # ============================================================
21
+ # Configuration
22
+ # ============================================================
23
+ APP_TITLE = "QuoteForge"
24
+ APP_SUBTITLE = "Industrial Quote Intelligence Platform"
25
+ DEFAULT_MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6")
26
+ MAIN_SHEET = "Sheet1"
27
+ NOTES_SHEET = "SME_Notes"
28
+ HEADERS = ["Request", "Information Extracted", "Design"]
29
+ DATA_LOCK = threading.Lock()
30
+ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin1234")
31
+
32
+ FONTS = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=DM+Sans:wght@300;400;500;600&display=swap"
33
+
34
+ CUSTOM_CSS = f"""
35
+ @import url('{FONTS}');
36
+
37
+ :root {{
38
+ --forge-black: #0a0a0b;
39
+ --forge-dark: #111114;
40
+ --forge-panel: #18181d;
41
+ --forge-border: #2a2a35;
42
+ --forge-border-bright: #3d3d50;
43
+ --forge-amber: #f59e0b;
44
+ --forge-amber-dim: #92610a;
45
+ --forge-amber-glow: rgba(245,158,11,0.15);
46
+ --forge-red: #ef4444;
47
+ --forge-green: #22c55e;
48
+ --forge-blue: #3b82f6;
49
+ --forge-text: #e8e8f0;
50
+ --forge-muted: #6b6b80;
51
+ --forge-mono: 'DM Mono', monospace;
52
+ --forge-display: 'Bebas Neue', sans-serif;
53
+ --forge-body: 'DM Sans', sans-serif;
54
+ }}
55
+
56
+ /* ── Global reset ── */
57
+ *, *::before, *::after {{ box-sizing: border-box; }}
58
+
59
+ .gradio-container {{
60
+ max-width: 100% !important;
61
+ padding: 0 !important;
62
+ margin: 0 !important;
63
+ background: var(--forge-black) !important;
64
+ font-family: var(--forge-body) !important;
65
+ min-height: 100vh;
66
+ }}
67
+
68
+ body, .dark {{
69
+ background: var(--forge-black) !important;
70
+ }}
71
+
72
+ /* ── Hide default gradio chrome ── */
73
+ footer {{ display: none !important; }}
74
+ .svelte-1ipelgc {{ display: none !important; }}
75
+
76
+ /* ── Header ── */
77
+ .forge-header {{
78
+ background: var(--forge-dark);
79
+ border-bottom: 1px solid var(--forge-border);
80
+ padding: 0 2rem;
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: space-between;
84
+ height: 64px;
85
+ position: sticky;
86
+ top: 0;
87
+ z-index: 100;
88
+ }}
89
+
90
+ .forge-logo {{
91
+ display: flex;
92
+ align-items: baseline;
93
+ gap: 0.75rem;
94
+ }}
95
+
96
+ .forge-logo-primary {{
97
+ font-family: var(--forge-display);
98
+ font-size: 2rem;
99
+ letter-spacing: 0.08em;
100
+ color: var(--forge-amber);
101
+ line-height: 1;
102
+ }}
103
+
104
+ .forge-logo-sub {{
105
+ font-family: var(--forge-mono);
106
+ font-size: 0.7rem;
107
+ color: var(--forge-muted);
108
+ letter-spacing: 0.2em;
109
+ text-transform: uppercase;
110
+ }}
111
+
112
+ .forge-badge {{
113
+ font-family: var(--forge-mono);
114
+ font-size: 0.65rem;
115
+ padding: 0.25rem 0.6rem;
116
+ border: 1px solid var(--forge-amber-dim);
117
+ color: var(--forge-amber);
118
+ letter-spacing: 0.15em;
119
+ text-transform: uppercase;
120
+ background: var(--forge-amber-glow);
121
+ }}
122
+
123
+ /* ── Tab navigation override ── */
124
+ .tab-nav {{
125
+ background: var(--forge-dark) !important;
126
+ border-bottom: 1px solid var(--forge-border) !important;
127
+ padding: 0 2rem !important;
128
+ gap: 0 !important;
129
+ }}
130
+
131
+ .tab-nav button {{
132
+ font-family: var(--forge-mono) !important;
133
+ font-size: 0.72rem !important;
134
+ letter-spacing: 0.12em !important;
135
+ text-transform: uppercase !important;
136
+ color: var(--forge-muted) !important;
137
+ background: transparent !important;
138
+ border: none !important;
139
+ border-bottom: 2px solid transparent !important;
140
+ padding: 1rem 1.5rem !important;
141
+ margin: 0 !important;
142
+ transition: all 0.2s !important;
143
+ border-radius: 0 !important;
144
+ }}
145
+
146
+ .tab-nav button:hover {{
147
+ color: var(--forge-text) !important;
148
+ background: transparent !important;
149
+ }}
150
+
151
+ .tab-nav button.selected {{
152
+ color: var(--forge-amber) !important;
153
+ border-bottom-color: var(--forge-amber) !important;
154
+ background: transparent !important;
155
+ }}
156
+
157
+ /* ── Page sections ── */
158
+ .forge-page {{
159
+ padding: 2.5rem 2rem;
160
+ max-width: 1400px;
161
+ margin: 0 auto;
162
+ }}
163
+
164
+ /* ── Section headers ── */
165
+ .forge-section-label {{
166
+ font-family: var(--forge-mono);
167
+ font-size: 0.65rem;
168
+ letter-spacing: 0.2em;
169
+ text-transform: uppercase;
170
+ color: var(--forge-amber);
171
+ margin-bottom: 0.5rem;
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 0.5rem;
175
+ }}
176
+
177
+ .forge-section-label::after {{
178
+ content: '';
179
+ flex: 1;
180
+ height: 1px;
181
+ background: var(--forge-border);
182
+ }}
183
+
184
+ .forge-section-title {{
185
+ font-family: var(--forge-display);
186
+ font-size: 3rem;
187
+ color: var(--forge-text);
188
+ letter-spacing: 0.05em;
189
+ line-height: 1;
190
+ margin-bottom: 0.75rem;
191
+ }}
192
+
193
+ .forge-section-desc {{
194
+ font-family: var(--forge-body);
195
+ font-size: 0.95rem;
196
+ color: var(--forge-muted);
197
+ line-height: 1.7;
198
+ max-width: 560px;
199
+ margin-bottom: 2rem;
200
+ }}
201
+
202
+ /* ── Cards / panels ── */
203
+ .forge-card {{
204
+ background: var(--forge-panel);
205
+ border: 1px solid var(--forge-border);
206
+ padding: 1.5rem;
207
+ position: relative;
208
+ }}
209
+
210
+ .forge-card::before {{
211
+ content: '';
212
+ position: absolute;
213
+ top: 0; left: 0;
214
+ width: 3px; height: 100%;
215
+ background: var(--forge-amber);
216
+ }}
217
+
218
+ /* ── Inputs ── */
219
+ label {{
220
+ font-family: var(--forge-mono) !important;
221
+ font-size: 0.68rem !important;
222
+ letter-spacing: 0.14em !important;
223
+ text-transform: uppercase !important;
224
+ color: var(--forge-muted) !important;
225
+ margin-bottom: 0.4rem !important;
226
+ }}
227
+
228
+ textarea, input[type=text], input[type=password], input[type=number] {{
229
+ font-family: var(--forge-mono) !important;
230
+ font-size: 0.85rem !important;
231
+ background: var(--forge-black) !important;
232
+ border: 1px solid var(--forge-border) !important;
233
+ color: var(--forge-text) !important;
234
+ border-radius: 0 !important;
235
+ transition: border-color 0.2s !important;
236
+ }}
237
+
238
+ textarea:focus, input:focus {{
239
+ border-color: var(--forge-amber) !important;
240
+ outline: none !important;
241
+ box-shadow: 0 0 0 1px var(--forge-amber-dim) !important;
242
+ }}
243
+
244
+ /* ── Buttons ── */
245
+ button.primary {{
246
+ font-family: var(--forge-mono) !important;
247
+ font-size: 0.75rem !important;
248
+ letter-spacing: 0.15em !important;
249
+ text-transform: uppercase !important;
250
+ background: var(--forge-amber) !important;
251
+ color: var(--forge-black) !important;
252
+ border: none !important;
253
+ border-radius: 0 !important;
254
+ padding: 0.75rem 1.5rem !important;
255
+ font-weight: 600 !important;
256
+ transition: all 0.2s !important;
257
+ cursor: pointer !important;
258
+ }}
259
+
260
+ button.primary:hover {{
261
+ background: #fbbf24 !important;
262
+ transform: translateY(-1px) !important;
263
+ box-shadow: 0 4px 20px rgba(245,158,11,0.3) !important;
264
+ }}
265
+
266
+ button.secondary {{
267
+ font-family: var(--forge-mono) !important;
268
+ font-size: 0.72rem !important;
269
+ letter-spacing: 0.12em !important;
270
+ text-transform: uppercase !important;
271
+ background: transparent !important;
272
+ color: var(--forge-text) !important;
273
+ border: 1px solid var(--forge-border-bright) !important;
274
+ border-radius: 0 !important;
275
+ padding: 0.65rem 1.25rem !important;
276
+ transition: all 0.2s !important;
277
+ }}
278
+
279
+ button.secondary:hover {{
280
+ border-color: var(--forge-amber) !important;
281
+ color: var(--forge-amber) !important;
282
+ }}
283
+
284
+ /* ── Status / alert banners ── */
285
+ .forge-alert {{
286
+ border: 1px solid;
287
+ padding: 1rem 1.25rem;
288
+ font-family: var(--forge-mono);
289
+ font-size: 0.78rem;
290
+ letter-spacing: 0.06em;
291
+ display: flex;
292
+ align-items: flex-start;
293
+ gap: 0.75rem;
294
+ margin-bottom: 1.5rem;
295
+ }}
296
+
297
+ .forge-alert.warn {{
298
+ border-color: var(--forge-amber-dim);
299
+ background: var(--forge-amber-glow);
300
+ color: var(--forge-amber);
301
+ }}
302
+
303
+ .forge-alert.error {{
304
+ border-color: #7f1d1d;
305
+ background: rgba(239,68,68,0.08);
306
+ color: var(--forge-red);
307
+ }}
308
+
309
+ .forge-alert.success {{
310
+ border-color: #14532d;
311
+ background: rgba(34,197,94,0.08);
312
+ color: var(--forge-green);
313
+ }}
314
+
315
+ .forge-alert.info {{
316
+ border-color: var(--forge-border-bright);
317
+ background: rgba(59,130,246,0.08);
318
+ color: #93c5fd;
319
+ }}
320
+
321
+ /* ── API key prompt ── */
322
+ #api-key-banner {{
323
+ background: linear-gradient(135deg, rgba(245,158,11,0.12), rgba(245,158,11,0.04));
324
+ border: 1px solid var(--forge-amber-dim);
325
+ padding: 1.5rem 2rem;
326
+ margin-bottom: 2rem;
327
+ display: flex;
328
+ align-items: center;
329
+ gap: 1.5rem;
330
+ flex-wrap: wrap;
331
+ }}
332
+
333
+ /* ── Data tables ── */
334
+ .gradio-dataframe {{
335
+ background: var(--forge-panel) !important;
336
+ border: 1px solid var(--forge-border) !important;
337
+ border-radius: 0 !important;
338
+ }}
339
+
340
+ .gradio-dataframe table th {{
341
+ font-family: var(--forge-mono) !important;
342
+ font-size: 0.65rem !important;
343
+ letter-spacing: 0.15em !important;
344
+ text-transform: uppercase !important;
345
+ color: var(--forge-amber) !important;
346
+ background: var(--forge-dark) !important;
347
+ border-bottom: 1px solid var(--forge-border) !important;
348
+ padding: 0.75rem 1rem !important;
349
+ }}
350
+
351
+ .gradio-dataframe table td {{
352
+ font-family: var(--forge-mono) !important;
353
+ font-size: 0.8rem !important;
354
+ color: var(--forge-text) !important;
355
+ background: transparent !important;
356
+ border-bottom: 1px solid var(--forge-border) !important;
357
+ padding: 0.65rem 1rem !important;
358
+ }}
359
+
360
+ .gradio-dataframe table tr:hover td {{
361
+ background: rgba(245,158,11,0.04) !important;
362
+ }}
363
+
364
+ /* ── Sliders ── */
365
+ input[type=range] {{
366
+ accent-color: var(--forge-amber) !important;
367
+ }}
368
+
369
+ /* ── Dropdown ── */
370
+ .wrap-inner {{
371
+ background: var(--forge-black) !important;
372
+ border: 1px solid var(--forge-border) !important;
373
+ border-radius: 0 !important;
374
+ font-family: var(--forge-mono) !important;
375
+ font-size: 0.82rem !important;
376
+ color: var(--forge-text) !important;
377
+ }}
378
+
379
+ /* ── File upload ── */
380
+ .upload-btn {{
381
+ border: 1px dashed var(--forge-border-bright) !important;
382
+ background: var(--forge-black) !important;
383
+ border-radius: 0 !important;
384
+ color: var(--forge-muted) !important;
385
+ font-family: var(--forge-mono) !important;
386
+ font-size: 0.78rem !important;
387
+ }}
388
+
389
+ /* ── Stat grid (admin) ── */
390
+ .forge-stat-grid {{
391
+ display: grid;
392
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
393
+ gap: 1px;
394
+ background: var(--forge-border);
395
+ border: 1px solid var(--forge-border);
396
+ margin-bottom: 2rem;
397
+ }}
398
+
399
+ .forge-stat {{
400
+ background: var(--forge-panel);
401
+ padding: 1.25rem 1.5rem;
402
+ display: flex;
403
+ flex-direction: column;
404
+ gap: 0.25rem;
405
+ }}
406
+
407
+ .forge-stat-value {{
408
+ font-family: var(--forge-display);
409
+ font-size: 2.2rem;
410
+ color: var(--forge-amber);
411
+ letter-spacing: 0.04em;
412
+ line-height: 1;
413
+ }}
414
+
415
+ .forge-stat-label {{
416
+ font-family: var(--forge-mono);
417
+ font-size: 0.62rem;
418
+ letter-spacing: 0.18em;
419
+ text-transform: uppercase;
420
+ color: var(--forge-muted);
421
+ }}
422
+
423
+ /* ── SML indicator ── */
424
+ .sml-badge {{
425
+ display: inline-flex;
426
+ align-items: center;
427
+ gap: 0.4rem;
428
+ font-family: var(--forge-mono);
429
+ font-size: 0.65rem;
430
+ letter-spacing: 0.12em;
431
+ text-transform: uppercase;
432
+ padding: 0.3rem 0.7rem;
433
+ border: 1px solid;
434
+ }}
435
+
436
+ .sml-badge.llm {{
437
+ border-color: #14532d;
438
+ color: var(--forge-green);
439
+ background: rgba(34,197,94,0.08);
440
+ }}
441
+
442
+ .sml-badge.sml {{
443
+ border-color: var(--forge-amber-dim);
444
+ color: var(--forge-amber);
445
+ background: var(--forge-amber-glow);
446
+ }}
447
+
448
+ /* ── Hero section ── */
449
+ .forge-hero {{
450
+ padding: 4rem 2rem 3rem;
451
+ max-width: 1400px;
452
+ margin: 0 auto;
453
+ display: grid;
454
+ grid-template-columns: 1fr 1fr;
455
+ gap: 4rem;
456
+ align-items: start;
457
+ }}
458
+
459
+ .forge-hero-visual {{
460
+ display: flex;
461
+ flex-direction: column;
462
+ gap: 1.5rem;
463
+ padding-top: 1rem;
464
+ }}
465
+
466
+ .forge-metric-row {{
467
+ display: flex;
468
+ gap: 1px;
469
+ background: var(--forge-border);
470
+ }}
471
+
472
+ .forge-metric {{
473
+ flex: 1;
474
+ background: var(--forge-panel);
475
+ padding: 1rem 1.25rem;
476
+ display: flex;
477
+ flex-direction: column;
478
+ gap: 0.2rem;
479
+ }}
480
+
481
+ .forge-metric-val {{
482
+ font-family: var(--forge-display);
483
+ font-size: 1.8rem;
484
+ color: var(--forge-amber);
485
+ }}
486
+
487
+ .forge-metric-key {{
488
+ font-family: var(--forge-mono);
489
+ font-size: 0.6rem;
490
+ color: var(--forge-muted);
491
+ letter-spacing: 0.15em;
492
+ text-transform: uppercase;
493
+ }}
494
+
495
+ .forge-divider {{
496
+ height: 1px;
497
+ background: var(--forge-border);
498
+ margin: 2rem 0;
499
+ }}
500
+
501
+ /* ── Admin terminal ── */
502
+ .forge-terminal-header {{
503
+ background: var(--forge-dark);
504
+ border: 1px solid var(--forge-border);
505
+ border-bottom: none;
506
+ padding: 0.75rem 1rem;
507
+ display: flex;
508
+ align-items: center;
509
+ gap: 0.5rem;
510
+ }}
511
+
512
+ .terminal-dot {{
513
+ width: 10px; height: 10px;
514
+ border-radius: 50%;
515
+ }}
516
+
517
+ .forge-terminal-body {{
518
+ background: var(--forge-black);
519
+ border: 1px solid var(--forge-border);
520
+ padding: 1.25rem;
521
+ font-family: var(--forge-mono);
522
+ font-size: 0.8rem;
523
+ color: var(--forge-text);
524
+ min-height: 60px;
525
+ line-height: 1.8;
526
+ }}
527
+
528
+ /* ── Responsive ── */
529
+ @media (max-width: 900px) {{
530
+ .forge-hero {{
531
+ grid-template-columns: 1fr;
532
+ }}
533
+ }}
534
+ """
535
+
536
+
537
+ # ============================================================
538
+ # Paths
539
+ # ============================================================
540
+ REPO_ROOT = Path(__file__).resolve().parent
541
+ REPO_DATA_DIR = REPO_ROOT / "data"
542
+ REPO_DATA_DIR.mkdir(parents=True, exist_ok=True)
543
+ SEED_WORKBOOK = REPO_DATA_DIR / "quote_request_training.xlsx"
544
+
545
+ if Path("/data").exists():
546
+ APP_DATA_DIR = Path("/data") / "quote_request_handler"
547
+ else:
548
+ APP_DATA_DIR = REPO_DATA_DIR
549
+
550
+ APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
551
+ EXPORT_DIR = APP_DATA_DIR / "exports"
552
+ EXPORT_DIR.mkdir(parents=True, exist_ok=True)
553
+ DATA_PATH = APP_DATA_DIR / "quote_request_training.xlsx"
554
+
555
+ DEFAULT_NOTES = [
556
+ "fan curves and AI selects fans",
557
+ "quote should call out unknowns clearly when application details are missing",
558
+ ]
559
+
560
+
561
+ # ============================================================
562
+ # Utilities
563
+ # ============================================================
564
+ def clean_text(value: Any) -> str:
565
+ if value is None:
566
+ return ""
567
+ if isinstance(value, float) and pd.isna(value):
568
+ return ""
569
+ return str(value).strip()
570
+
571
+
572
+ def summarize_text(text: str, limit: int = 90) -> str:
573
+ text = clean_text(text).replace("\n", " ")
574
+ return text if len(text) <= limit else text[: limit - 3] + "..."
575
+
576
+
577
+ def safe_bool_text(flag: bool) -> str:
578
+ return "Yes" if flag else "No"
579
+
580
+
581
+ def strip_code_fences(text: str) -> str:
582
+ text = clean_text(text)
583
+ if text.startswith("```"):
584
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
585
+ text = re.sub(r"\s*```$", "", text)
586
+ return text.strip()
587
+
588
+
589
+ def extract_first_balanced_json(text: str) -> str:
590
+ text = strip_code_fences(text)
591
+ start = text.find("{")
592
+ if start == -1:
593
+ raise ValueError(f"No JSON object found:\n{text}")
594
+ depth, in_string, escape = 0, False, False
595
+ for idx in range(start, len(text)):
596
+ char = text[idx]
597
+ if in_string:
598
+ if escape:
599
+ escape = False
600
+ elif char == "\\":
601
+ escape = True
602
+ elif char == '"':
603
+ in_string = False
604
+ continue
605
+ if char == '"':
606
+ in_string = True
607
+ elif char == "{":
608
+ depth += 1
609
+ elif char == "}":
610
+ depth -= 1
611
+ if depth == 0:
612
+ return text[start: idx + 1]
613
+ raise ValueError(f"JSON truncated:\n{text}")
614
+
615
+
616
+ def normalize_list(value: Any) -> List[str]:
617
+ if isinstance(value, list):
618
+ return [clean_text(v) for v in value if clean_text(v)]
619
+ if isinstance(value, str):
620
+ lines = [re.sub(r"^[-*\d.)\s]+", "", line).strip() for line in value.splitlines()]
621
+ return [line for line in lines if line]
622
+ return []
623
+
624
+
625
+ def ensure_seed_exists(path: Path) -> None:
626
+ if path.exists():
627
+ return
628
+ seed_df = pd.DataFrame([
629
+ {
630
+ "Request": "15000 CFM pharmaceutical powder, corrosive dust, need fan and collector recommendation",
631
+ "Information Extracted": "Pharmaceutical powder; corrosive dust; 15000 CFM; high-efficiency filtration, corrosion-resistant construction, combustibility review needed.",
632
+ "Design": "Recommend cartridge/pulse-jet collector with PTFE media, stainless construction, fan review, NFPA combustibility confirmation before final quote.",
633
+ },
634
+ {
635
+ "Request": "Need a dust collection upgrade for metal grinding line, 8000 CFM, sparks possible",
636
+ "Information Extracted": "Metal grinding dust; 8000 CFM; spark risk; abrasion-resistant design, spark mitigation, combustible metal hazard review.",
637
+ "Design": "Collector with spark control, abrasion-resistant internals, combustible metals safety review before quoting.",
638
+ },
639
+ ])
640
+ notes_df = pd.DataFrame([[note] for note in DEFAULT_NOTES])
641
+ with pd.ExcelWriter(path, engine="openpyxl") as writer:
642
+ seed_df.to_excel(writer, sheet_name=MAIN_SHEET, index=False)
643
+ notes_df.to_excel(writer, sheet_name=NOTES_SHEET, index=False, header=False)
644
+
645
+
646
+ # ============================================================
647
+ # Workbook store
648
+ # ============================================================
649
+ @dataclass
650
+ class WorkbookBundle:
651
+ dataset: pd.DataFrame
652
+ extra_sheets: Dict[str, pd.DataFrame]
653
+
654
+
655
+ class WorkbookStore:
656
+ def __init__(self, data_path: Path, seed_path: Optional[Path] = None):
657
+ self.path = data_path
658
+ self.seed_path = seed_path
659
+ self.ensure_exists()
660
+
661
+ def ensure_exists(self) -> None:
662
+ if self.path.exists():
663
+ return
664
+ if self.seed_path and self.seed_path.exists() and self.seed_path.resolve() != self.path.resolve():
665
+ shutil.copy2(self.seed_path, self.path)
666
+ return
667
+ ensure_seed_exists(self.path)
668
+
669
+ def load_bundle(self) -> WorkbookBundle:
670
+ self.ensure_exists()
671
+ xls = pd.ExcelFile(self.path)
672
+ main = pd.read_excel(self.path, sheet_name=xls.sheet_names[0])
673
+ main.columns = [clean_text(c) for c in main.columns]
674
+ for col in HEADERS:
675
+ if col not in main.columns:
676
+ main[col] = ""
677
+ main = main[HEADERS].copy()
678
+ for col in HEADERS:
679
+ main[col] = main[col].map(clean_text)
680
+
681
+ extra_sheets: Dict[str, pd.DataFrame] = {}
682
+ for sheet in xls.sheet_names[1:]:
683
+ extra_sheets[sheet] = pd.read_excel(self.path, sheet_name=sheet, header=None)
684
+ if NOTES_SHEET not in extra_sheets:
685
+ extra_sheets[NOTES_SHEET] = pd.DataFrame([[note] for note in DEFAULT_NOTES])
686
+
687
+ return WorkbookBundle(dataset=main, extra_sheets=extra_sheets)
688
+
689
+ def save_bundle(self, bundle: WorkbookBundle) -> None:
690
+ bundle.dataset = bundle.dataset.fillna("")
691
+ with pd.ExcelWriter(self.path, engine="openpyxl") as writer:
692
+ bundle.dataset.to_excel(writer, sheet_name=MAIN_SHEET, index=False)
693
+ for sheet_name, df in bundle.extra_sheets.items():
694
+ df.to_excel(writer, sheet_name=sheet_name, index=False, header=False)
695
+
696
+ def replace_from_upload(self, uploaded_path: str) -> None:
697
+ xls = pd.ExcelFile(uploaded_path)
698
+ main = pd.read_excel(uploaded_path, sheet_name=xls.sheet_names[0])
699
+ main.columns = [clean_text(c) for c in main.columns]
700
+ for col in HEADERS:
701
+ if col not in main.columns:
702
+ main[col] = ""
703
+ main = main[HEADERS].copy()
704
+ for col in HEADERS:
705
+ main[col] = main[col].map(clean_text)
706
+ extras: Dict[str, pd.DataFrame] = {}
707
+ for sheet in xls.sheet_names[1:]:
708
+ extras[sheet] = pd.read_excel(uploaded_path, sheet_name=sheet, header=None)
709
+ if NOTES_SHEET not in extras:
710
+ extras[NOTES_SHEET] = pd.DataFrame([[note] for note in DEFAULT_NOTES])
711
+ self.save_bundle(WorkbookBundle(dataset=main, extra_sheets=extras))
712
+
713
+
714
+ # ============================================================
715
+ # SML (Small Machine Learning) Backend
716
+ # β€” Runs entirely locally, no API key required
717
+ # β€” Uses TF-IDF retrieval + rule-based extraction + template generation
718
+ # ============================================================
719
+ class SMLBackend:
720
+ """
721
+ Lightweight local inference engine.
722
+ Extracts structured fields via regex + keyword heuristics,
723
+ then generates quote guidance by template-blending the
724
+ top-k nearest historical examples.
725
+ """
726
+
727
+ AIRFLOW_PATTERN = re.compile(r"(\d[\d,]*)\s*(?:cfm|acfm|scfm)", re.IGNORECASE)
728
+ MATERIAL_KEYWORDS = {
729
+ "pharmaceutical": ["pharma", "pharmaceutical", "drug", "api ", "gmp"],
730
+ "metal grinding": ["grind", "metal grind", "steel grind", "aluminum grind"],
731
+ "wood dust": ["wood", "sawdust", "lumber", "mdf", "plywood"],
732
+ "chemical": ["chemical", "solvent", "acid", "caustic", "reactive"],
733
+ "food": ["food", "grain", "flour", "sugar", "starch", "spice"],
734
+ "plastic": ["plastic", "polymer", "pellet", "resin", "pvc"],
735
+ "cement/mineral": ["cement", "concrete", "lime", "silica", "mineral"],
736
+ "general industrial": [],
737
+ }
738
+ HAZARD_KEYWORDS = {
739
+ "combustible": ["combustible", "flammable", "explosive", "deflagration", "nfpa 652", "nfpa 654"],
740
+ "corrosive": ["corrosive", "corrosion", "acid", "caustic", "hcl", "h2so4", "stainless"],
741
+ "spark risk": ["spark", "sparks", "ignition", "hot work", "grinding", "welding"],
742
+ "toxic": ["toxic", "carcinogen", "hazmat", "osha", "pel ", "tlv "],
743
+ "high humidity": ["humid", "moisture", "wet", "condensation", "steam"],
744
+ }
745
+ COLLECTOR_KEYWORDS = {
746
+ "cartridge collector": ["cartridge", "nano", "nanofiber", "pleated"],
747
+ "baghouse": ["baghouse", "bag house", "pulse jet", "pulse-jet", "shaker", "reverse air"],
748
+ "cyclone": ["cyclone", "centrifugal", "pre-separator"],
749
+ "wet scrubber": ["wet scrubber", "scrubber", "venturi", "wet collector"],
750
+ "electrostatic": ["esp", "electrostatic", "precipitator"],
751
+ }
752
+
753
+ def __init__(self, dataset: pd.DataFrame, notes: List[str]):
754
+ self.dataset = dataset
755
+ self.notes = notes
756
+ self.vectorizer: Optional[TfidfVectorizer] = None
757
+ self.matrix = None
758
+ self.examples = dataset[
759
+ (dataset["Request"].map(clean_text) != "") &
760
+ ((dataset["Information Extracted"].map(clean_text) != "") |
761
+ (dataset["Design"].map(clean_text) != ""))
762
+ ].reset_index(drop=True)
763
+ self._build_index()
764
+
765
+ def _build_index(self) -> None:
766
+ if self.examples.empty:
767
+ return
768
+ corpus = (
769
+ self.examples["Request"].fillna("") + " || " +
770
+ self.examples["Information Extracted"].fillna("") + " || " +
771
+ self.examples["Design"].fillna("")
772
+ ).tolist()
773
+ self.vectorizer = TfidfVectorizer(ngram_range=(1, 2), stop_words="english", max_features=5000)
774
+ self.matrix = self.vectorizer.fit_transform(corpus)
775
+
776
+ def _match_keywords(self, text: str, kw_dict: Dict[str, List[str]]) -> List[str]:
777
+ text_lower = text.lower()
778
+ matches = []
779
+ for category, keywords in kw_dict.items():
780
+ if any(kw in text_lower for kw in keywords):
781
+ matches.append(category)
782
+ return matches
783
+
784
+ def _extract_cfm(self, text: str) -> str:
785
+ m = self.AIRFLOW_PATTERN.search(text)
786
+ return m.group(0).upper() if m else "Not specified β€” confirm with customer"
787
+
788
+ def _detect_material(self, text: str) -> str:
789
+ text_lower = text.lower()
790
+ for material, keywords in self.MATERIAL_KEYWORDS.items():
791
+ if material == "general industrial":
792
+ continue
793
+ if any(kw in text_lower for kw in keywords):
794
+ return material
795
+ return "General industrial dust"
796
+
797
+ def _detect_hazards(self, text: str) -> List[str]:
798
+ return self._match_keywords(text, self.HAZARD_KEYWORDS) or ["No specific hazard keywords detected β€” verify with SME"]
799
+
800
+ def _suggest_collector(self, text: str, material: str, hazards: List[str]) -> str:
801
+ text_lower = text.lower()
802
+ for ctype, keywords in self.COLLECTOR_KEYWORDS.items():
803
+ if any(kw in text_lower for kw in keywords):
804
+ return ctype
805
+ # heuristic fallback by material
806
+ if "pharma" in material:
807
+ return "cartridge collector (PTFE media recommended for pharma)"
808
+ if "metal" in material:
809
+ return "cartridge or baghouse with spark arrestor"
810
+ if "wood" in material:
811
+ return "baghouse or cartridge collector (check NFPA 652/664)"
812
+ if "cement" in material or "mineral" in material:
813
+ return "pulse-jet baghouse"
814
+ return "pulse-jet cartridge collector (general recommendation)"
815
+
816
+ def retrieve(self, request_text: str, sme_text: str, top_k: int = 4) -> pd.DataFrame:
817
+ if self.vectorizer is None or self.matrix is None:
818
+ return pd.DataFrame(columns=["Request", "Information Extracted", "Design", "Similarity"])
819
+ query = f"{clean_text(request_text)} || {clean_text(sme_text)}"
820
+ qv = self.vectorizer.transform([query])
821
+ scores = cosine_similarity(qv, self.matrix).ravel()
822
+ top_idx = scores.argsort()[::-1][:max(1, min(top_k, len(scores)))]
823
+ out = self.examples.iloc[top_idx].copy()
824
+ out["Similarity"] = scores[top_idx]
825
+ out = out[["Request", "Information Extracted", "Design", "Similarity"]].reset_index(drop=True)
826
+ out["Similarity"] = out["Similarity"].map(lambda x: round(float(x), 4))
827
+ return out
828
+
829
+ def generate(self, request_text: str, sme_text: str = "", top_k: int = 4) -> Dict[str, Any]:
830
+ combined = f"{request_text} {sme_text}"
831
+ cfm = self._extract_cfm(combined)
832
+ material = self._detect_material(combined)
833
+ hazards = self._detect_hazards(combined)
834
+ collector = self._suggest_collector(combined, material, hazards)
835
+ retrieved = self.retrieve(request_text, sme_text, top_k)
836
+
837
+ # Build information_extracted by blending extraction + top example context
838
+ info_parts = [
839
+ f"Application: {material}.",
840
+ f"Airflow: {cfm}.",
841
+ f"Detected hazards: {'; '.join(hazards)}.",
842
+ ]
843
+ if sme_text:
844
+ info_parts.append(f"SME notes: {sme_text.strip('.')}.")
845
+ if self.notes:
846
+ info_parts.append(f"Business context: {'; '.join(self.notes[:3])}.")
847
+ if not retrieved.empty:
848
+ best = retrieved.iloc[0]
849
+ if best["Similarity"] > 0.05 and clean_text(best["Information Extracted"]):
850
+ info_parts.append(f"Similar prior case: {summarize_text(best['Information Extracted'], 120)}")
851
+ information_extracted = " ".join(info_parts)
852
+
853
+ # Design guidance
854
+ design_parts = [
855
+ f"Recommend a {collector}.",
856
+ ]
857
+ if "combustible" in hazards:
858
+ design_parts.append("Include NFPA combustibility review and explosion protection before quoting final scope.")
859
+ if "corrosive" in hazards:
860
+ design_parts.append("Specify corrosion-resistant construction (304/316 SS or coated carbon steel); confirm chemical compatibility.")
861
+ if "spark risk" in hazards:
862
+ design_parts.append("Add spark detection and suppression or pre-separator spark arrestor.")
863
+ if "pharma" in material:
864
+ design_parts.append("GMP cleanability, PTFE filter media, and cGMP documentation package required.")
865
+ if not retrieved.empty:
866
+ best = retrieved.iloc[0]
867
+ if best["Similarity"] > 0.05 and clean_text(best["Design"]):
868
+ design_parts.append(f"Informed by similar case: {summarize_text(best['Design'], 120)}")
869
+ design_parts.append("Confirm all open questions with customer before issuing formal quote.")
870
+ design = " ".join(design_parts)
871
+
872
+ open_questions = ["Confirm airflow (CFM) if not specified", "Verify inlet concentration and particle size", "Confirm electrical classification (Class/Div or Zone)"]
873
+ if cfm == "Not specified β€” confirm with customer":
874
+ open_questions.insert(0, "Airflow CFM not found in request β€” must be confirmed")
875
+
876
+ assumptions = [
877
+ "SML local inference used β€” no LLM API key configured.",
878
+ f"Material classification: {material} (keyword-based, verify with SME).",
879
+ f"Collector suggestion: {collector} (heuristic, review before quoting).",
880
+ "All outputs are draft guidance only and require SME validation.",
881
+ ]
882
+
883
+ return {
884
+ "information_extracted": information_extracted,
885
+ "design": design,
886
+ "quote_inputs": {
887
+ "application": material,
888
+ "airflow_cfm": cfm,
889
+ "dust_or_material": material,
890
+ "collector_type": collector,
891
+ "fan_notes": "Fan selection pending CFM and static pressure confirmation.",
892
+ "material_of_construction": "TBD β€” depends on hazard/corrosion review",
893
+ "filter_media": "TBD β€” depends on application",
894
+ "safety_notes": "; ".join(hazards),
895
+ "open_questions": open_questions,
896
+ },
897
+ "assumptions": assumptions,
898
+ "retrieved_examples": retrieved,
899
+ "raw_model_output": f"[SML Backend] material={material}, cfm={cfm}, hazards={hazards}, collector={collector}",
900
+ "backend": "sml",
901
+ }
902
+
903
+
904
+ # ============================================================
905
+ # LLM + Engine
906
+ # ============================================================
907
+ def _get_anthropic_client(api_key_override: str = ""):
908
+ try:
909
+ from anthropic import Anthropic
910
+ except ImportError:
911
+ return None
912
+ key = api_key_override.strip() or os.getenv("ANTHROPIC_API_KEY", "").strip()
913
+ if not key:
914
+ return None
915
+ try:
916
+ return Anthropic(api_key=key)
917
+ except Exception:
918
+ return None
919
+
920
+
921
+ class QuoteRequestEngine:
922
+ def __init__(self, store: WorkbookStore):
923
+ self.store = store
924
+ self.reload()
925
+
926
+ def reload(self) -> None:
927
+ bundle = self.store.load_bundle()
928
+ self.bundle = bundle
929
+ self.dataset = bundle.dataset.copy().reset_index(drop=True)
930
+ self.notes = self._flatten_notes(bundle.extra_sheets)
931
+ self.examples = self.dataset[
932
+ (self.dataset["Request"].map(clean_text) != "") &
933
+ ((self.dataset["Information Extracted"].map(clean_text) != "") |
934
+ (self.dataset["Design"].map(clean_text) != ""))
935
+ ].reset_index(drop=True)
936
+
937
+ self.vectorizer: Optional[TfidfVectorizer] = None
938
+ self.matrix = None
939
+ if not self.examples.empty:
940
+ corpus = (
941
+ self.examples["Request"].fillna("") + " || " +
942
+ self.examples["Information Extracted"].fillna("") + " || " +
943
+ self.examples["Design"].fillna("")
944
+ ).tolist()
945
+ self.vectorizer = TfidfVectorizer(ngram_range=(1, 2), stop_words="english")
946
+ self.matrix = self.vectorizer.fit_transform(corpus)
947
+
948
+ self._sml = SMLBackend(self.dataset, self.notes)
949
+
950
+ @staticmethod
951
+ def _flatten_notes(extra_sheets: Dict[str, pd.DataFrame]) -> List[str]:
952
+ notes: List[str] = []
953
+ for df in extra_sheets.values():
954
+ for item in df.fillna("").astype(str).values.ravel().tolist():
955
+ item = clean_text(item)
956
+ if item and item.lower() != "nan":
957
+ notes.append(item)
958
+ return notes
959
+
960
+ def retrieve_examples(self, request_text: str, sme_text: str, top_k: int = 4) -> pd.DataFrame:
961
+ if self.vectorizer is None or self.matrix is None or self.examples.empty:
962
+ return pd.DataFrame(columns=["Request", "Information Extracted", "Design", "Similarity"])
963
+ query = f"{clean_text(request_text)} || {clean_text(sme_text)}"
964
+ qv = self.vectorizer.transform([query])
965
+ scores = cosine_similarity(qv, self.matrix).ravel()
966
+ top_idx = scores.argsort()[::-1][:max(1, min(top_k, len(scores)))]
967
+ out = self.examples.iloc[top_idx].copy()
968
+ out["Similarity"] = scores[top_idx]
969
+ out = out[["Request", "Information Extracted", "Design", "Similarity"]].reset_index(drop=True)
970
+ out["Similarity"] = out["Similarity"].map(lambda x: round(float(x), 4))
971
+ return out
972
+
973
+ def _build_messages(self, request_text: str, sme_text: str, retrieved: pd.DataFrame) -> Tuple[str, str]:
974
+ system_prompt = """
975
+ You are an industrial quote-request handler for a future quote automation system.
976
+ Return only valid JSON with this exact schema:
977
+ {
978
+ "information_extracted": "string",
979
+ "design": "string",
980
+ "quote_inputs": {
981
+ "application": "string",
982
+ "airflow_cfm": "string",
983
+ "dust_or_material": "string",
984
+ "collector_type": "string",
985
+ "fan_notes": "string",
986
+ "material_of_construction": "string",
987
+ "filter_media": "string",
988
+ "safety_notes": "string",
989
+ "open_questions": ["string"]
990
+ },
991
+ "assumptions": ["string"]
992
+ }
993
+ Rules: treat requests as customer language that may be incomplete. SME notes are authoritative. Make design output quote-ready. Do not invent pricing or lead times. Clearly state unknowns. Do not wrap in markdown.
994
+ """.strip()
995
+
996
+ if retrieved.empty:
997
+ examples_text = "No prior examples available."
998
+ else:
999
+ blocks = []
1000
+ for idx, row in retrieved.iterrows():
1001
+ blocks.append(f"Example {idx + 1}\nRequest: {row['Request']}\nSME: {row['Information Extracted']}\nDesign: {row['Design']}\nSimilarity: {row['Similarity']}")
1002
+ examples_text = "\n\n".join(blocks)
1003
+
1004
+ notes_block = "\n".join(f"- {n}" for n in self.notes[:30]) if self.notes else "- None"
1005
+
1006
+ user_prompt = f"""
1007
+ Customer Request: {clean_text(request_text) or '[Not provided]'}
1008
+ SME Knowledge: {clean_text(sme_text) or '[Not provided]'}
1009
+ Global SME Notes:\n{notes_block}
1010
+ Historical Examples:\n{examples_text}
1011
+ Generate quote-ready response using the schema exactly.
1012
+ """.strip()
1013
+ return system_prompt, user_prompt
1014
+
1015
+ def _repair_json(self, broken: str, client) -> Dict[str, Any]:
1016
+ response = client.messages.create(
1017
+ model=DEFAULT_MODEL, max_tokens=1600, temperature=0,
1018
+ system="Repair malformed JSON. Return only valid JSON.",
1019
+ messages=[{"role": "user", "content": f"Repair into valid JSON, no markdown:\n{broken}"}],
1020
+ )
1021
+ raw = "".join(b.text for b in response.content if getattr(b, "type", None) == "text").strip()
1022
+ return self._parse_json(raw, client=client, allow_repair=False)
1023
+
1024
+ def _parse_json(self, text: str, client=None, allow_repair: bool = True) -> Dict[str, Any]:
1025
+ text = strip_code_fences(text)
1026
+ try:
1027
+ data = json.loads(extract_first_balanced_json(text))
1028
+ except Exception:
1029
+ if allow_repair and client:
1030
+ data = self._repair_json(text, client)
1031
+ else:
1032
+ raise
1033
+ data.setdefault("information_extracted", "")
1034
+ data.setdefault("design", "")
1035
+ data.setdefault("quote_inputs", {})
1036
+ data.setdefault("assumptions", [])
1037
+ data["information_extracted"] = clean_text(data["information_extracted"])
1038
+ data["design"] = clean_text(data["design"])
1039
+ if not isinstance(data.get("quote_inputs"), dict):
1040
+ data["quote_inputs"] = {}
1041
+ data["assumptions"] = normalize_list(data["assumptions"])
1042
+ return data
1043
+
1044
+ def generate_quote(
1045
+ self,
1046
+ request_text: str,
1047
+ sme_text: str = "",
1048
+ top_k: int = 4,
1049
+ temperature: float = 0.1,
1050
+ api_key_override: str = "",
1051
+ ) -> Dict[str, Any]:
1052
+ request_text = clean_text(request_text)
1053
+ sme_text = clean_text(sme_text)
1054
+ if not request_text and not sme_text:
1055
+ raise ValueError("Provide a request or SME notes before generating.")
1056
+
1057
+ client = _get_anthropic_client(api_key_override)
1058
+
1059
+ # ── LLM path ──
1060
+ if client:
1061
+ retrieved = self.retrieve_examples(request_text, sme_text, top_k)
1062
+ system_prompt, user_prompt = self._build_messages(request_text, sme_text, retrieved)
1063
+ response = client.messages.create(
1064
+ model=DEFAULT_MODEL,
1065
+ max_tokens=1800,
1066
+ temperature=float(temperature),
1067
+ system=system_prompt,
1068
+ messages=[{"role": "user", "content": user_prompt}],
1069
+ )
1070
+ raw = "".join(b.text for b in response.content if getattr(b, "type", None) == "text").strip()
1071
+ parsed = self._parse_json(raw, client=client, allow_repair=True)
1072
+ parsed["raw_model_output"] = raw
1073
+ parsed["retrieved_examples"] = retrieved
1074
+ parsed["request"] = request_text
1075
+ parsed["sml_input"] = sme_text
1076
+ parsed["backend"] = "llm"
1077
+ return parsed
1078
+
1079
+ # ── SML fallback ──
1080
+ return self._sml.generate(request_text, sme_text, top_k)
1081
+
1082
+
1083
+ # ============================================================
1084
+ # Global store + engine
1085
+ # ============================================================
1086
+ store = WorkbookStore(DATA_PATH, seed_path=SEED_WORKBOOK if SEED_WORKBOOK.exists() else None)
1087
+ engine = QuoteRequestEngine(store)
1088
+
1089
+
1090
+ # ============================================================
1091
+ # Helper functions for UI
1092
+ # ============================================================
1093
+ def get_dataset_preview() -> pd.DataFrame:
1094
+ engine.reload()
1095
+ df = engine.dataset.copy().reset_index(drop=True)
1096
+ if df.empty:
1097
+ return pd.DataFrame(columns=["row_id"] + HEADERS)
1098
+ df.insert(0, "row_id", df.index + 1)
1099
+ return df
1100
+
1101
+
1102
+ def get_note_preview() -> pd.DataFrame:
1103
+ engine.reload()
1104
+ if not engine.notes:
1105
+ return pd.DataFrame({"note_id": [], "SME Note": []})
1106
+ return pd.DataFrame({"note_id": list(range(1, len(engine.notes) + 1)), "SME Note": engine.notes})
1107
+
1108
+
1109
+ def get_row_choices() -> List[Tuple[str, int]]:
1110
+ df = get_dataset_preview()
1111
+ if df.empty:
1112
+ return []
1113
+ return [(f"{int(r.row_id)} | {summarize_text(r.Request, 80)}", int(r.row_id)) for r in df.itertuples(index=False)]
1114
+
1115
+
1116
+ def get_downloadable_path() -> str:
1117
+ store.ensure_exists()
1118
+ return str(DATA_PATH)
1119
+
1120
+
1121
+ def api_key_active(override: str = "") -> bool:
1122
+ return bool(_get_anthropic_client(override))
1123
+
1124
+
1125
+ def backend_label(override: str = "") -> str:
1126
+ if api_key_active(override):
1127
+ return '<span class="sml-badge llm">⬀ LLM · Claude Active</span>'
1128
+ return '<span class="sml-badge sml">⬀ SML · Local Inference</span>'
1129
+
1130
+
1131
+ def status_html() -> str:
1132
+ rows = len(engine.dataset)
1133
+ notes = len(engine.notes)
1134
+ backend = "LLM (Claude)" if api_key_active() else "SML (Local)"
1135
+ return f"""<div class="forge-stat-grid">
1136
+ <div class="forge-stat"><div class="forge-stat-value">{rows}</div><div class="forge-stat-label">Training Rows</div></div>
1137
+ <div class="forge-stat"><div class="forge-stat-value">{notes}</div><div class="forge-stat-label">SME Notes</div></div>
1138
+ <div class="forge-stat"><div class="forge-stat-value">{"βœ“" if api_key_active() else "β€”"}</div><div class="forge-stat-label">API Key</div></div>
1139
+ <div class="forge-stat"><div class="forge-stat-value" style="font-size:1.1rem;padding-top:0.5rem">{backend}</div><div class="forge-stat-label">Active Backend</div></div>
1140
+ </div>"""
1141
+
1142
+
1143
+ def format_quote_inputs(qi: Dict[str, Any]) -> str:
1144
+ if not qi:
1145
+ return ""
1146
+ keys = ["application", "airflow_cfm", "dust_or_material", "collector_type",
1147
+ "fan_notes", "material_of_construction", "filter_media", "safety_notes", "open_questions"]
1148
+ blocks = []
1149
+ for key in keys:
1150
+ val = qi.get(key, "")
1151
+ if isinstance(val, list):
1152
+ val = "\n".join(f" Β· {clean_text(v)}" for v in val if clean_text(v))
1153
+ else:
1154
+ val = clean_text(val)
1155
+ blocks.append(f"{key.replace('_',' ').upper()}\n{val or '[Not provided]'}")
1156
+ return "\n\n".join(blocks)
1157
+
1158
+
1159
+ def format_assumptions(items: List[str]) -> str:
1160
+ if not items:
1161
+ return "[None listed]"
1162
+ return "\n".join(f"Β· {clean_text(i)}" for i in items if clean_text(i))
1163
+
1164
+
1165
+ # ============================================================
1166
+ # Action handlers
1167
+ # ============================================================
1168
+ def generate_quote_action(request_text, sme_text, top_k, temperature, api_key_override):
1169
+ try:
1170
+ result = engine.generate_quote(
1171
+ request_text=request_text, sme_text=sme_text,
1172
+ top_k=int(top_k), temperature=float(temperature),
1173
+ api_key_override=api_key_override,
1174
+ )
1175
+ be = result.get("backend", "sml")
1176
+ be_label = "⬀ LLM · Claude" if be == "llm" else "⬀ SML · Local Inference"
1177
+ return (
1178
+ result.get("information_extracted", ""),
1179
+ result.get("design", ""),
1180
+ format_quote_inputs(result.get("quote_inputs", {})),
1181
+ format_assumptions(result.get("assumptions", [])),
1182
+ result.get("retrieved_examples", pd.DataFrame()),
1183
+ result.get("raw_model_output", ""),
1184
+ f'<div class="forge-alert {"success" if be == "llm" else "warn"}">{be_label}</div>',
1185
+ )
1186
+ except Exception as e:
1187
+ empty = pd.DataFrame(columns=["Request", "Information Extracted", "Design", "Similarity"])
1188
+ return "", "", "", str(e), empty, "", f'<div class="forge-alert error">Error: {e}</div>'
1189
+
1190
+
1191
+ def save_generated_row(request_text, info, design):
1192
+ request_text, info, design = clean_text(request_text), clean_text(info), clean_text(design)
1193
+ if not any([request_text, info, design]):
1194
+ raise gr.Error("Nothing to save.")
1195
+ with DATA_LOCK:
1196
+ bundle = store.load_bundle()
1197
+ bundle.dataset = pd.concat([bundle.dataset, pd.DataFrame([{"Request": request_text, "Information Extracted": info, "Design": design}])], ignore_index=True)
1198
+ store.save_bundle(bundle)
1199
+ engine.reload()
1200
+ return "βœ“ Row saved.", get_dataset_preview(), get_note_preview(), gr.Dropdown(choices=get_row_choices(), value=None), get_downloadable_path(), status_html()
1201
+
1202
+
1203
+ def add_request_only(request_text):
1204
+ request_text = clean_text(request_text)
1205
+ if not request_text:
1206
+ raise gr.Error("Enter a request.")
1207
+ with DATA_LOCK:
1208
+ bundle = store.load_bundle()
1209
+ bundle.dataset = pd.concat([bundle.dataset, pd.DataFrame([{"Request": request_text, "Information Extracted": "", "Design": ""}])], ignore_index=True)
1210
+ store.save_bundle(bundle)
1211
+ engine.reload()
1212
+ return "βœ“ Request appended.", get_dataset_preview(), get_note_preview(), gr.Dropdown(choices=get_row_choices(), value=None), get_downloadable_path(), status_html()
1213
+
1214
+
1215
+ def add_full_training_row(req, sme, design):
1216
+ req = clean_text(req)
1217
+ if not req:
1218
+ raise gr.Error("Request field required.")
1219
+ with DATA_LOCK:
1220
+ bundle = store.load_bundle()
1221
+ bundle.dataset = pd.concat([bundle.dataset, pd.DataFrame([{"Request": req, "Information Extracted": clean_text(sme), "Design": clean_text(design)}])], ignore_index=True)
1222
+ store.save_bundle(bundle)
1223
+ engine.reload()
1224
+ return "βœ“ Full row added.", get_dataset_preview(), get_note_preview(), gr.Dropdown(choices=get_row_choices(), value=None), get_downloadable_path(), status_html()
1225
+
1226
+
1227
+ def add_global_sme_note(note_text):
1228
+ note_text = clean_text(note_text)
1229
+ if not note_text:
1230
+ raise gr.Error("Enter a note.")
1231
+ with DATA_LOCK:
1232
+ bundle = store.load_bundle()
1233
+ notes_df = bundle.extra_sheets.get(NOTES_SHEET, pd.DataFrame(columns=[0]))
1234
+ notes_df = pd.concat([notes_df, pd.DataFrame([[note_text]])], ignore_index=True)
1235
+ bundle.extra_sheets[NOTES_SHEET] = notes_df
1236
+ store.save_bundle(bundle)
1237
+ engine.reload()
1238
+ return "βœ“ Note added.", get_dataset_preview(), get_note_preview(), gr.Dropdown(choices=get_row_choices(), value=None), get_downloadable_path(), status_html()
1239
+
1240
+
1241
+ def load_row_for_edit(row_id):
1242
+ if row_id is None:
1243
+ return "", "", ""
1244
+ df = get_dataset_preview()
1245
+ row = df[df["row_id"] == int(row_id)]
1246
+ if row.empty:
1247
+ return "", "", ""
1248
+ r = row.iloc[0]
1249
+ return r["Request"], r["Information Extracted"], r["Design"]
1250
+
1251
+
1252
+ def update_row_fields(row_id, req, sme, design):
1253
+ if row_id is None:
1254
+ raise gr.Error("Select a row.")
1255
+ with DATA_LOCK:
1256
+ bundle = store.load_bundle()
1257
+ df = bundle.dataset.copy().reset_index(drop=True)
1258
+ idx = int(row_id) - 1
1259
+ if idx < 0 or idx >= len(df):
1260
+ raise gr.Error("Row out of range.")
1261
+ df.at[idx, "Request"] = clean_text(req)
1262
+ df.at[idx, "Information Extracted"] = clean_text(sme)
1263
+ df.at[idx, "Design"] = clean_text(design)
1264
+ bundle.dataset = df
1265
+ store.save_bundle(bundle)
1266
+ engine.reload()
1267
+ return f"βœ“ Row {row_id} updated.", get_dataset_preview(), get_note_preview(), gr.Dropdown(choices=get_row_choices(), value=row_id), get_downloadable_path(), status_html()
1268
+
1269
+
1270
+ def append_sme_to_row(row_id, sme_text):
1271
+ if row_id is None:
1272
+ raise gr.Error("Select a row.")
1273
+ sme_text = clean_text(sme_text)
1274
+ if not sme_text:
1275
+ raise gr.Error("Enter SME knowledge.")
1276
+ with DATA_LOCK:
1277
+ bundle = store.load_bundle()
1278
+ df = bundle.dataset.copy().reset_index(drop=True)
1279
+ idx = int(row_id) - 1
1280
+ if idx < 0 or idx >= len(df):
1281
+ raise gr.Error("Row out of range.")
1282
+ existing = clean_text(df.at[idx, "Information Extracted"])
1283
+ df.at[idx, "Information Extracted"] = f"{existing}\n{sme_text}".strip() if existing else sme_text
1284
+ bundle.dataset = df
1285
+ store.save_bundle(bundle)
1286
+ engine.reload()
1287
+ return f"βœ“ SME appended to row {row_id}.", get_dataset_preview(), get_note_preview(), gr.Dropdown(choices=get_row_choices(), value=row_id), get_downloadable_path(), status_html()
1288
+
1289
+
1290
+ def replace_dataset(uploaded_file):
1291
+ if not uploaded_file:
1292
+ raise gr.Error("Upload a .xlsx file first.")
1293
+ with DATA_LOCK:
1294
+ store.replace_from_upload(uploaded_file)
1295
+ engine.reload()
1296
+ return "βœ“ Workbook replaced.", get_dataset_preview(), get_note_preview(), gr.Dropdown(choices=get_row_choices(), value=None), get_downloadable_path(), status_html()
1297
+
1298
+
1299
+ def export_training_assets():
1300
+ engine.reload()
1301
+ df = engine.dataset.copy()
1302
+ csv_path = EXPORT_DIR / "quote_request_training.csv"
1303
+ jsonl_path = EXPORT_DIR / "quote_request_training.jsonl"
1304
+ df.to_csv(csv_path, index=False)
1305
+ with open(jsonl_path, "w", encoding="utf-8") as f:
1306
+ for _, row in df.iterrows():
1307
+ f.write(json.dumps({"request": clean_text(row.get("Request", "")), "sme_knowledge": clean_text(row.get("Information Extracted", "")), "design": clean_text(row.get("Design", ""))}, ensure_ascii=False) + "\n")
1308
+ return f"βœ“ Exported {len(df)} rows.", str(csv_path), str(jsonl_path)
1309
+
1310
+
1311
+ def admin_login(password):
1312
+ if password == ADMIN_PASSWORD:
1313
+ return gr.update(visible=False), gr.update(visible=True), "βœ“ Access granted."
1314
+ return gr.update(visible=True), gr.update(visible=False), "βœ— Invalid password."
1315
+
1316
+
1317
+ def set_api_key_session(key, override_state):
1318
+ key = key.strip()
1319
+ if not key:
1320
+ return override_state, '<div class="forge-alert error">⚠ Enter an API key.</div>'
1321
+ client = _get_anthropic_client(key)
1322
+ if client:
1323
+ return key, '<div class="forge-alert success">βœ“ API key accepted β€” LLM backend active.</div>'
1324
+ return override_state, '<div class="forge-alert error">βœ— Key rejected or Anthropic SDK not available.</div>'
1325
+
1326
+
1327
+ def refresh_all():
1328
+ return get_dataset_preview(), get_note_preview(), gr.Dropdown(choices=get_row_choices(), value=None), get_downloadable_path(), status_html()
1329
+
1330
+
1331
+ # ============================================================
1332
+ # UI
1333
+ # ============================================================
1334
+ with gr.Blocks(title=APP_TITLE, css=CUSTOM_CSS, theme=gr.themes.Base()) as demo:
1335
+
1336
+ api_key_state = gr.State("")
1337
+
1338
+ # ── Header ──
1339
+ gr.HTML(f"""
1340
+ <div class="forge-header">
1341
+ <div class="forge-logo">
1342
+ <span class="forge-logo-primary">{APP_TITLE}</span>
1343
+ <span class="forge-logo-sub">{APP_SUBTITLE}</span>
1344
+ </div>
1345
+ <div style="display:flex;align-items:center;gap:1rem;">
1346
+ <span class="forge-badge">MVP v2</span>
1347
+ <span class="forge-badge" id="hdr-backend">{"LLM ACTIVE" if api_key_active() else "SML MODE"}</span>
1348
+ </div>
1349
+ </div>
1350
+ """)
1351
+
1352
+ with gr.Tabs(elem_classes="main-tabs"):
1353
+
1354
+ # ════════════════════════════════════════════════════
1355
+ # TAB 1 Β· SUBMIT A REQUEST (public intake)
1356
+ # ════════════════════════════════════════════════════
1357
+ with gr.Tab("REQUEST INTAKE"):
1358
+ gr.HTML("""
1359
+ <div class="forge-hero">
1360
+ <div>
1361
+ <div class="forge-section-label">Quote Intelligence</div>
1362
+ <div class="forge-section-title">SUBMIT YOUR<br>QUOTE REQUEST</div>
1363
+ <div class="forge-section-desc">
1364
+ Paste your customer request below. Our engine β€” powered by Claude LLM or our local SML inference model β€” will extract application details, identify hazards, and generate quote-ready design guidance in seconds.
1365
+ </div>
1366
+ </div>
1367
+ <div class="forge-hero-visual">
1368
+ <div class="forge-metric-row">
1369
+ <div class="forge-metric"><div class="forge-metric-val">A→C</div><div class="forge-metric-key">Request to Design</div></div>
1370
+ <div class="forge-metric"><div class="forge-metric-val">SML</div><div class="forge-metric-key">Offline Fallback</div></div>
1371
+ <div class="forge-metric"><div class="forge-metric-val">∞</div><div class="forge-metric-key">Training Loop</div></div>
1372
+ </div>
1373
+ <div class="forge-card" style="font-family:var(--forge-mono);font-size:0.78rem;color:var(--forge-muted);line-height:1.9;">
1374
+ <div style="color:var(--forge-amber);margin-bottom:0.5rem;font-size:0.65rem;letter-spacing:0.2em;">HOW IT WORKS</div>
1375
+ 01 Β· Paste customer request<br>
1376
+ 02 Β· Add optional SME context<br>
1377
+ 03 Β· Engine retrieves similar cases<br>
1378
+ 04 Β· LLM or SML generates guidance<br>
1379
+ 05 Β· Review β†’ save to training set
1380
+ </div>
1381
+ </div>
1382
+ </div>
1383
+ <div style="max-width:1400px;margin:0 auto;padding:0 2rem;">
1384
+ """)
1385
+
1386
+ # API key banner (shown when no key in env)
1387
+ api_key_banner_visible = not api_key_active()
1388
+ with gr.Group(visible=api_key_banner_visible, elem_id="api-key-section") as api_key_section:
1389
+ gr.HTML("""
1390
+ <div class="forge-alert warn" style="margin-bottom:0.75rem;">
1391
+ ⚠ &nbsp;<strong>No Anthropic API key detected.</strong> &nbsp;Running in SML (local inference) mode.
1392
+ Enter a key below to enable Claude LLM backend. Or continue β€” SML works offline.
1393
+ </div>
1394
+ """)
1395
+ with gr.Row():
1396
+ api_key_input = gr.Textbox(
1397
+ label="Anthropic API Key (session only β€” not stored)",
1398
+ placeholder="sk-ant-...",
1399
+ type="password",
1400
+ scale=4,
1401
+ )
1402
+ api_key_btn = gr.Button("Activate LLM", variant="primary", scale=1)
1403
+ api_key_status = gr.HTML("")
1404
+
1405
+ with gr.Row():
1406
+ with gr.Column(scale=3):
1407
+ request_input = gr.Textbox(
1408
+ label="Customer Request",
1409
+ lines=7,
1410
+ placeholder="e.g. 15000 CFM pharmaceutical powder, corrosive dust, need fan and collector recommendation",
1411
+ )
1412
+ sme_input = gr.Textbox(
1413
+ label="SME Knowledge / Domain Notes (optional)",
1414
+ lines=5,
1415
+ placeholder="Add expert context that should influence design guidance...",
1416
+ )
1417
+ with gr.Row():
1418
+ top_k_input = gr.Slider(1, 8, value=4, step=1, label="Historical Examples")
1419
+ temperature_input = gr.Slider(0.0, 1.0, value=0.1, step=0.05, label="Temperature (LLM only)")
1420
+ with gr.Row():
1421
+ generate_btn = gr.Button("Generate Quote Guidance", variant="primary")
1422
+ save_generated_btn = gr.Button("Save as Training Row", variant="secondary")
1423
+
1424
+ save_generated_status = gr.HTML("")
1425
+
1426
+ with gr.Column(scale=3):
1427
+ backend_indicator = gr.HTML(f'<div class="forge-alert info" style="margin-bottom:1rem;">Select backend and submit request to begin.</div>')
1428
+ info_output = gr.Textbox(label="Information Extracted (Col B)", lines=7)
1429
+ design_output = gr.Textbox(label="Design / Quote Guidance (Col C)", lines=8)
1430
+
1431
+ with gr.Row():
1432
+ with gr.Column():
1433
+ quote_inputs_output = gr.Textbox(label="Structured Quote Inputs", lines=14)
1434
+ with gr.Column():
1435
+ assumptions_output = gr.Textbox(label="Assumptions & Unknowns", lines=10)
1436
+
1437
+ retrieved_output = gr.Dataframe(
1438
+ headers=["Request", "Information Extracted", "Design", "Similarity"],
1439
+ datatype=["str", "str", "str", "number"],
1440
+ label="Retrieved Historical Examples",
1441
+ interactive=False,
1442
+ wrap=True,
1443
+ )
1444
+ raw_output = gr.Textbox(label="Raw Model Output (debug)", lines=5, visible=False)
1445
+ gr.HTML("</div>") # close forge-page
1446
+
1447
+ # ════════════════════════════════════════════════════
1448
+ # TAB 2 Β· ADMIN TERMINAL
1449
+ # ════════════════════════════════════════════════════
1450
+ with gr.Tab("ADMIN TERMINAL"):
1451
+
1452
+ # ── Login gate ──
1453
+ with gr.Group(visible=True) as admin_login_panel:
1454
+ gr.HTML("""
1455
+ <div class="forge-page" style="max-width:480px;">
1456
+ <div class="forge-terminal-header">
1457
+ <div class="terminal-dot" style="background:#ef4444"></div>
1458
+ <div class="terminal-dot" style="background:#f59e0b"></div>
1459
+ <div class="terminal-dot" style="background:#22c55e"></div>
1460
+ <span style="font-family:var(--forge-mono);font-size:0.7rem;color:var(--forge-muted);margin-left:0.5rem;">QUOTEFORGE ADMIN TERMINAL β€” RESTRICTED ACCESS</span>
1461
+ </div>
1462
+ <div class="forge-terminal-body">
1463
+ <span style="color:var(--forge-amber);">QuoteForge</span> <span style="color:var(--forge-muted);">v2.0</span> β€” Authentication required<br>
1464
+ <span style="color:var(--forge-muted);">Set ADMIN_PASSWORD env var to change default (admin1234)</span>
1465
+ </div>
1466
+ </div>
1467
+ """)
1468
+ with gr.Row(elem_classes="forge-page"):
1469
+ admin_pw_input = gr.Textbox(label="Admin Password", type="password", placeholder="Enter password...", scale=3)
1470
+ admin_login_btn = gr.Button("Authenticate", variant="primary", scale=1)
1471
+ admin_login_status = gr.HTML("")
1472
+
1473
+ # ── Admin workspace (hidden until auth) ──
1474
+ with gr.Group(visible=False) as admin_workspace:
1475
+ gr.HTML("""
1476
+ <div class="forge-page">
1477
+ <div class="forge-terminal-header">
1478
+ <div class="terminal-dot" style="background:#ef4444"></div>
1479
+ <div class="terminal-dot" style="background:#f59e0b"></div>
1480
+ <div class="terminal-dot" style="background:#22c55e"></div>
1481
+ <span style="font-family:var(--forge-mono);font-size:0.7rem;color:var(--forge-muted);margin-left:0.5rem;">QUOTEFORGE ADMIN TERMINAL β€” SESSION ACTIVE</span>
1482
+ </div>
1483
+ """)
1484
+
1485
+ with gr.Row(elem_classes="forge-page"):
1486
+ admin_status_html = gr.HTML(status_html())
1487
+ refresh_admin_btn = gr.Button("↻ Refresh", variant="secondary")
1488
+
1489
+ gr.HTML('<div class="forge-page"><div class="forge-section-label">Dataset Management</div></div>')
1490
+
1491
+ with gr.Tabs(elem_classes="forge-page"):
1492
+
1493
+ with gr.Tab("Add Request Only"):
1494
+ add_request_box = gr.Textbox(label="New Request (Column A)", lines=6)
1495
+ add_request_btn = gr.Button("Append to Column A", variant="primary")
1496
+ add_request_status = gr.HTML("")
1497
+
1498
+ with gr.Tab("Add Full A/B/C Row"):
1499
+ full_request_box = gr.Textbox(label="Request (A)", lines=4)
1500
+ full_sme_box = gr.Textbox(label="Information Extracted (B)", lines=5)
1501
+ full_design_box = gr.Textbox(label="Design Guidance (C)", lines=6)
1502
+ add_full_btn = gr.Button("Append Full Row", variant="primary")
1503
+ add_full_status = gr.HTML("")
1504
+
1505
+ with gr.Tab("Append SME to Row"):
1506
+ row_selector = gr.Dropdown(choices=get_row_choices(), label="Select Row", value=None)
1507
+ append_sme_box = gr.Textbox(label="SME Knowledge to Append", lines=6)
1508
+ append_sme_btn = gr.Button("Append to Selected Row", variant="primary")
1509
+ append_sme_status = gr.HTML("")
1510
+
1511
+ with gr.Tab("Edit Row"):
1512
+ load_row_btn = gr.Button("Load Selected Row", variant="secondary")
1513
+ edit_request_box = gr.Textbox(label="Request (A)", lines=4)
1514
+ edit_sme_box = gr.Textbox(label="Information Extracted (B)", lines=5)
1515
+ edit_design_box = gr.Textbox(label="Design Guidance (C)", lines=6)
1516
+ update_row_btn = gr.Button("Save Changes", variant="primary")
1517
+ update_row_status = gr.HTML("")
1518
+
1519
+ with gr.Tab("Global SME Notes"):
1520
+ global_note_box = gr.Textbox(label="New Global SME Note", lines=4)
1521
+ add_note_btn = gr.Button("Save Note", variant="primary")
1522
+ add_note_status = gr.HTML("")
1523
+
1524
+ gr.HTML('<div class="forge-page"><div class="forge-section-label">Dataset Viewer & Export</div></div>')
1525
+
1526
+ with gr.Row(elem_classes="forge-page"):
1527
+ with gr.Column(scale=3):
1528
+ dataset_preview = gr.Dataframe(
1529
+ value=get_dataset_preview(),
1530
+ headers=["row_id"] + HEADERS,
1531
+ datatype=["number", "str", "str", "str"],
1532
+ label="Training Dataset",
1533
+ interactive=False,
1534
+ wrap=True,
1535
+ )
1536
+ notes_preview = gr.Dataframe(
1537
+ value=get_note_preview(),
1538
+ headers=["note_id", "SME Note"],
1539
+ datatype=["number", "str"],
1540
+ label="Global SME Notes",
1541
+ interactive=False,
1542
+ wrap=True,
1543
+ )
1544
+ with gr.Column(scale=1):
1545
+ upload_file = gr.File(label="Upload Replacement Workbook (.xlsx)", file_types=[".xlsx"], type="filepath")
1546
+ replace_btn = gr.Button("Replace Dataset", variant="secondary")
1547
+ replace_status = gr.HTML("")
1548
+ dataset_download = gr.File(label="Download Current Workbook", value=get_downloadable_path(), interactive=False)
1549
+ export_btn = gr.Button("Export ML Assets", variant="primary")
1550
+ export_status = gr.HTML("")
1551
+ export_csv_file = gr.File(label="CSV Export", interactive=False)
1552
+ export_jsonl_file = gr.File(label="JSONL Export", interactive=False)
1553
+
1554
+ gr.HTML("</div>") # close terminal body div
1555
+
1556
+ # ════════════════════════════════════════════════════
1557
+ # Event wiring
1558
+ # ════════════════════════════════════════════════════
1559
+
1560
+ # API key activation
1561
+ api_key_btn.click(
1562
+ fn=set_api_key_session,
1563
+ inputs=[api_key_input, api_key_state],
1564
+ outputs=[api_key_state, api_key_status],
1565
+ )
1566
+
1567
+ # Generate
1568
+ generate_btn.click(
1569
+ fn=generate_quote_action,
1570
+ inputs=[request_input, sme_input, top_k_input, temperature_input, api_key_state],
1571
+ outputs=[info_output, design_output, quote_inputs_output, assumptions_output, retrieved_output, raw_output, backend_indicator],
1572
+ )
1573
+
1574
+ # Save generated row
1575
+ save_generated_btn.click(
1576
+ fn=save_generated_row,
1577
+ inputs=[request_input, info_output, design_output],
1578
+ outputs=[save_generated_status, dataset_preview, notes_preview, row_selector, dataset_download, admin_status_html],
1579
+ )
1580
+
1581
+ # Admin login
1582
+ admin_login_btn.click(
1583
+ fn=admin_login,
1584
+ inputs=[admin_pw_input],
1585
+ outputs=[admin_login_panel, admin_workspace, admin_login_status],
1586
+ )
1587
+
1588
+ # Admin actions
1589
+ add_request_btn.click(
1590
+ fn=add_request_only,
1591
+ inputs=[add_request_box],
1592
+ outputs=[add_request_status, dataset_preview, notes_preview, row_selector, dataset_download, admin_status_html],
1593
+ )
1594
+
1595
+ add_full_btn.click(
1596
+ fn=add_full_training_row,
1597
+ inputs=[full_request_box, full_sme_box, full_design_box],
1598
+ outputs=[add_full_status, dataset_preview, notes_preview, row_selector, dataset_download, admin_status_html],
1599
+ )
1600
+
1601
+ append_sme_btn.click(
1602
+ fn=append_sme_to_row,
1603
+ inputs=[row_selector, append_sme_box],
1604
+ outputs=[append_sme_status, dataset_preview, notes_preview, row_selector, dataset_download, admin_status_html],
1605
+ )
1606
+
1607
+ load_row_btn.click(
1608
+ fn=load_row_for_edit,
1609
+ inputs=[row_selector],
1610
+ outputs=[edit_request_box, edit_sme_box, edit_design_box],
1611
+ )
1612
+
1613
+ update_row_btn.click(
1614
+ fn=update_row_fields,
1615
+ inputs=[row_selector, edit_request_box, edit_sme_box, edit_design_box],
1616
+ outputs=[update_row_status, dataset_preview, notes_preview, row_selector, dataset_download, admin_status_html],
1617
+ )
1618
+
1619
+ add_note_btn.click(
1620
+ fn=add_global_sme_note,
1621
+ inputs=[global_note_box],
1622
+ outputs=[add_note_status, dataset_preview, notes_preview, row_selector, dataset_download, admin_status_html],
1623
+ )
1624
+
1625
+ replace_btn.click(
1626
+ fn=replace_dataset,
1627
+ inputs=[upload_file],
1628
+ outputs=[replace_status, dataset_preview, notes_preview, row_selector, dataset_download, admin_status_html],
1629
+ )
1630
+
1631
+ export_btn.click(
1632
+ fn=export_training_assets,
1633
+ inputs=[],
1634
+ outputs=[export_status, export_csv_file, export_jsonl_file],
1635
+ )
1636
+
1637
+ refresh_admin_btn.click(
1638
+ fn=refresh_all,
1639
+ inputs=[],
1640
+ outputs=[dataset_preview, notes_preview, row_selector, dataset_download, admin_status_html],
1641
+ )
1642
+
1643
+
1644
+ def main() -> None:
1645
+ demo.queue(default_concurrency_limit=8).launch(
1646
+ server_name="0.0.0.0",
1647
+ server_port=int(os.getenv("PORT", "7860")),
1648
+ )
1649
+
1650
+
1651
+ if __name__ == "__main__":
1652
+ main()
requirements (4).txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=6.0,<7
2
+ pandas>=2.2,<3
3
+ openpyxl>=3.1,<4
4
+ scikit-learn>=1.5,<2
5
+ anthropic>=0.49,<1
6
+ numpy>=1.26,<3