KinetoLabs Claude Opus 4.5 commited on
Commit
f3ebc82
·
1 Parent(s): 8771f89

Fix critical model implementations and add sample scenarios

Browse files

Model Fixes (verified against official Qwen3-VL-Embedding repo):
- Embedding: Use last-token pooling instead of mean pooling
- Embedding: Fix dimension from 768/384 to 4096
- Reranker: Use yes/no LM head weights + sigmoid instead of CLS norm
- Apply fixes in both models/real.py and rag/vectorstore.py

Additional Changes:
- Add sample scenarios with real fire damage images
- Add E2E tests with Playwright
- Fix Gradio 6.x tab navigation tests
- Add logging configuration
- Improve UI form handling

All 179 unit tests pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ sample_images/*.jpg filter=lfs diff=lfs merge=lfs -text
CLAUDE.md CHANGED
@@ -22,7 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
22
 
23
  | Component | Technology |
24
  |-----------|------------|
25
- | UI Framework | Gradio 4.x |
26
  | Vision/Generation | Qwen3-VL-30B-A3B-Instruct |
27
  | Embeddings | Qwen3-VL-Embedding-8B |
28
  | Reranker | Qwen3-VL-Reranker-8B |
@@ -31,6 +31,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
31
  | PDF Generation | Pandoc 3.x |
32
  | Package Manager | pip + requirements.txt |
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  ## Development Commands
35
 
36
  ```sh
 
22
 
23
  | Component | Technology |
24
  |-----------|------------|
25
+ | UI Framework | Gradio 6.x |
26
  | Vision/Generation | Qwen3-VL-30B-A3B-Instruct |
27
  | Embeddings | Qwen3-VL-Embedding-8B |
28
  | Reranker | Qwen3-VL-Reranker-8B |
 
31
  | PDF Generation | Pandoc 3.x |
32
  | Package Manager | pip + requirements.txt |
33
 
34
+ ## UI Components (Gradio 6.x)
35
+
36
+ The frontend uses optimized input components:
37
+
38
+ | Field | Component | Notes |
39
+ |-------|-----------|-------|
40
+ | State | `gr.Dropdown` | 50 US states + DC + territories |
41
+ | Dates | `gr.DateTime` | Calendar picker, no time selection |
42
+ | ZIP Code | `gr.Textbox` + blur validation | Real-time format validation |
43
+ | Credentials | `gr.Dropdown(multiselect=True)` | CIH, CSP, PE, etc. |
44
+ | Floor | `gr.Dropdown` | Basement through Roof |
45
+ | Ceiling Height | `gr.Dropdown` + custom option | 8-20 ft presets |
46
+ | Image Upload | `gr.Files(file_count="multiple")` | Batch upload support |
47
+
48
+ **Keyboard Shortcuts:**
49
+ - `Ctrl+1` through `Ctrl+5`: Navigate between tabs
50
+
51
  ## Development Commands
52
 
53
  ```sh
app.py CHANGED
@@ -5,11 +5,51 @@ Main Gradio application entry point with session state and tab validation.
5
 
6
  import gradio as gr
7
 
 
8
  from config.settings import settings
 
 
 
 
 
 
 
9
  from models.loader import get_models
10
  from ui.state import SessionState, create_new_session, session_to_json, session_from_json
11
  from ui.storage import get_head_html
12
  from ui.tabs import project, rooms, images, observations, results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  def create_app() -> gr.Blocks:
@@ -22,6 +62,7 @@ def create_app() -> gr.Blocks:
22
  # localStorage JS will be injected there
23
  with gr.Blocks(
24
  title="FDAM AI Pipeline - Fire Damage Assessment",
 
25
  ) as app:
26
  # Session state (stored in Gradio State component)
27
  session_state = gr.State(value=create_new_session())
@@ -46,30 +87,113 @@ def create_app() -> gr.Blocks:
46
  """
47
  )
48
 
49
- # Tab navigation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  with gr.Tabs() as tabs:
51
  # Tab 1: Project Information
52
- with gr.Tab("1. Project Info", id=0):
 
53
  tab1 = project.create_tab()
54
 
55
  # Tab 2: Building/Rooms
56
- with gr.Tab("2. Building/Rooms", id=1):
 
57
  tab2 = rooms.create_tab()
58
 
59
  # Tab 3: Images
60
- with gr.Tab("3. Images", id=2):
 
61
  tab3 = images.create_tab()
62
 
63
  # Tab 4: Observations
64
- with gr.Tab("4. Observations", id=3):
 
65
  tab4 = observations.create_tab()
66
 
67
  # Tab 5: Generate Results
68
- with gr.Tab("5. Generate Results", id=4):
 
69
  tab5 = results.create_tab()
70
 
71
  # --- Event Handlers ---
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  # Tab 1: Project Info
74
  tab1["validate_btn"].click(
75
  fn=project.validate_and_continue,
@@ -95,6 +219,13 @@ def create_app() -> gr.Blocks:
95
  ],
96
  )
97
 
 
 
 
 
 
 
 
98
  # Tab 2: Building/Rooms
99
  tab2["add_room_btn"].click(
100
  fn=rooms.add_room,
@@ -104,7 +235,8 @@ def create_app() -> gr.Blocks:
104
  tab2["room_floor"],
105
  tab2["room_length"],
106
  tab2["room_width"],
107
- tab2["room_height"],
 
108
  ],
109
  outputs=[
110
  session_state,
@@ -117,18 +249,27 @@ def create_app() -> gr.Blocks:
117
  tab2["room_floor"],
118
  tab2["room_length"],
119
  tab2["room_width"],
120
- tab2["room_height"],
 
121
  ],
122
  )
123
 
 
 
 
 
 
 
 
124
  tab2["clear_form_btn"].click(
125
- fn=lambda: ("", "", None, None, None),
126
  outputs=[
127
  tab2["room_name"],
128
  tab2["room_floor"],
129
  tab2["room_length"],
130
  tab2["room_width"],
131
- tab2["room_height"],
 
132
  ],
133
  )
134
 
@@ -169,20 +310,11 @@ def create_app() -> gr.Blocks:
169
  )
170
 
171
  tab2["back_btn"].click(
172
- fn=lambda: 0,
173
  outputs=[tabs],
174
  )
175
 
176
  # Tab 3: Images
177
- # Update room dropdown when entering tab
178
- tabs.select(
179
- fn=lambda session, selected: (
180
- images.update_room_choices(session) if selected == 2 else gr.update()
181
- ),
182
- inputs=[session_state, tabs],
183
- outputs=[tab3["room_select"]],
184
- )
185
-
186
  tab3["add_image_btn"].click(
187
  fn=images.add_image,
188
  inputs=[
@@ -243,7 +375,7 @@ def create_app() -> gr.Blocks:
243
  )
244
 
245
  tab3["back_btn"].click(
246
- fn=lambda: 1,
247
  outputs=[tabs],
248
  )
249
 
@@ -276,20 +408,11 @@ def create_app() -> gr.Blocks:
276
  )
277
 
278
  tab4["back_btn"].click(
279
- fn=lambda: 2,
280
  outputs=[tabs],
281
  )
282
 
283
  # Tab 5: Generate Results
284
- # Update preflight check when entering tab
285
- tabs.select(
286
- fn=lambda session, selected: (
287
- results.check_preflight(session) if selected == 4 else ""
288
- ),
289
- inputs=[session_state, tabs],
290
- outputs=[tab5["preflight_status"]],
291
- )
292
-
293
  tab5["generate_btn"].click(
294
  fn=results.generate_assessment,
295
  inputs=[session_state],
@@ -321,20 +444,18 @@ def create_app() -> gr.Blocks:
321
  )
322
 
323
  tab5["back_btn"].click(
324
- fn=lambda: 3,
325
  outputs=[tabs],
326
  )
327
 
328
- # --- Session Resume Handlers ---
329
- # Load form data when navigating to tabs
 
330
 
331
- # Tab 1 (Project): Load project form fields
332
- tabs.select(
333
- fn=lambda session, selected: (
334
- project.load_form_from_session(session) if selected == 0
335
- else tuple([gr.update()] * 12)
336
- ),
337
- inputs=[session_state, tabs],
338
  outputs=[
339
  tab1["project_name"],
340
  tab1["address"],
@@ -351,13 +472,10 @@ def create_app() -> gr.Blocks:
351
  ],
352
  )
353
 
354
- # Tab 2 (Rooms): Load room table and stats
355
- tabs.select(
356
- fn=lambda session, selected: (
357
- rooms.load_from_session(session) if selected == 1
358
- else (gr.update(), gr.update(), gr.update(), gr.update())
359
- ),
360
- inputs=[session_state, tabs],
361
  outputs=[
362
  tab2["rooms_table"],
363
  tab2["room_count"],
@@ -366,27 +484,28 @@ def create_app() -> gr.Blocks:
366
  ],
367
  )
368
 
369
- # Tab 3 (Images): Load gallery and count (room dropdown already handled above)
370
- tabs.select(
371
- fn=lambda session, selected: (
372
- images.load_from_session(session) if selected == 2
373
- else (gr.update(), gr.update(), gr.update())
374
- ),
375
- inputs=[session_state, tabs],
 
 
 
376
  outputs=[
 
377
  tab3["images_gallery"],
378
  tab3["image_count"],
379
  tab3["resume_warning"],
380
  ],
381
  )
382
 
383
- # Tab 4 (Observations): Load observation form fields
384
- tabs.select(
385
- fn=lambda session, selected: (
386
- observations.load_form_from_session(session) if selected == 3
387
- else tuple([gr.update()] * 15)
388
- ),
389
- inputs=[session_state, tabs],
390
  outputs=[
391
  tab4["smoke_odor"],
392
  tab4["odor_intensity"],
@@ -406,21 +525,29 @@ def create_app() -> gr.Blocks:
406
  ],
407
  )
408
 
 
 
 
 
 
 
 
409
  return app
410
 
411
 
412
  def main():
413
  """Entry point for the application."""
414
- print(f"Starting FDAM AI Pipeline...")
415
- print(f"Mock models: {settings.mock_models}")
416
- print(f"Server: {settings.server_host}:{settings.server_port}")
 
417
 
418
  app = create_app()
419
  app.launch(
420
  server_name=settings.server_host,
421
  server_port=settings.server_port,
422
  share=False,
423
- head=get_head_html(), # Inject localStorage JavaScript
424
  )
425
 
426
 
 
5
 
6
  import gradio as gr
7
 
8
+ from config.logging import setup_logging
9
  from config.settings import settings
10
+
11
+ # Initialize logging before any other imports that might log
12
+ setup_logging(settings.log_level)
13
+
14
+ import logging
15
+ logger = logging.getLogger(__name__)
16
+
17
  from models.loader import get_models
18
  from ui.state import SessionState, create_new_session, session_to_json, session_from_json
19
  from ui.storage import get_head_html
20
  from ui.tabs import project, rooms, images, observations, results
21
+ from ui import samples
22
+
23
+
24
+ # Keyboard shortcuts JavaScript (Ctrl+1-5 for tab navigation)
25
+ KEYBOARD_JS = """
26
+ <script>
27
+ document.addEventListener('keydown', (e) => {
28
+ if (e.ctrlKey && e.key >= '1' && e.key <= '5') {
29
+ e.preventDefault();
30
+ const tabIds = [
31
+ 'tab-project-button', 'tab-rooms-button', 'tab-images-button',
32
+ 'tab-observations-button', 'tab-results-button'
33
+ ];
34
+ const tabIndex = parseInt(e.key) - 1;
35
+ const tabButton = document.getElementById(tabIds[tabIndex]);
36
+ if (tabButton) {
37
+ tabButton.click();
38
+ }
39
+ }
40
+ });
41
+ </script>
42
+ """
43
+
44
+ # Validation CSS classes
45
+ VALIDATION_CSS = """
46
+ .valid-field input, .valid-field textarea {
47
+ border-color: #66bb6a !important;
48
+ }
49
+ .invalid-field input, .invalid-field textarea {
50
+ border-color: #ef5350 !important;
51
+ }
52
+ """
53
 
54
 
55
  def create_app() -> gr.Blocks:
 
62
  # localStorage JS will be injected there
63
  with gr.Blocks(
64
  title="FDAM AI Pipeline - Fire Damage Assessment",
65
+ css=VALIDATION_CSS,
66
  ) as app:
67
  # Session state (stored in Gradio State component)
68
  session_state = gr.State(value=create_new_session())
 
87
  """
88
  )
89
 
90
+ # Sample loader dropdown
91
+ with gr.Row():
92
+ sample_dropdown = gr.Dropdown(
93
+ label="Load Sample",
94
+ choices=samples.get_sample_choices(),
95
+ value="",
96
+ elem_id="sample_dropdown",
97
+ scale=2,
98
+ )
99
+ sample_status = gr.HTML(
100
+ value="",
101
+ elem_id="sample_status",
102
+ scale=3,
103
+ )
104
+
105
+ # Tab navigation (elem_id for stable JS selectors - Gradio appends "-button" for tab buttons)
106
+ # Store Tab references for individual select event handlers
107
  with gr.Tabs() as tabs:
108
  # Tab 1: Project Information
109
+ tab_project = gr.Tab("1. Project Info", id=0, elem_id="tab-project")
110
+ with tab_project:
111
  tab1 = project.create_tab()
112
 
113
  # Tab 2: Building/Rooms
114
+ tab_rooms = gr.Tab("2. Building/Rooms", id=1, elem_id="tab-rooms")
115
+ with tab_rooms:
116
  tab2 = rooms.create_tab()
117
 
118
  # Tab 3: Images
119
+ tab_images = gr.Tab("3. Images", id=2, elem_id="tab-images")
120
+ with tab_images:
121
  tab3 = images.create_tab()
122
 
123
  # Tab 4: Observations
124
+ tab_observations = gr.Tab("4. Observations", id=3, elem_id="tab-observations")
125
+ with tab_observations:
126
  tab4 = observations.create_tab()
127
 
128
  # Tab 5: Generate Results
129
+ tab_results = gr.Tab("5. Generate Results", id=4, elem_id="tab-results")
130
+ with tab_results:
131
  tab5 = results.create_tab()
132
 
133
  # --- Event Handlers ---
134
 
135
+ # Sample Loader
136
+ def handle_sample_load(scenario_id: str, current_session: SessionState):
137
+ """Handle sample dropdown selection."""
138
+ if not scenario_id:
139
+ # Empty selection, do nothing
140
+ return (
141
+ current_session, # session_state unchanged
142
+ *project.load_form_from_session(current_session), # 12 form values
143
+ gr.update(), # tabs unchanged
144
+ "", # clear status
145
+ "", # reset dropdown
146
+ )
147
+
148
+ # Load the sample
149
+ new_session = samples.load_sample(scenario_id)
150
+ if not new_session:
151
+ return (
152
+ current_session,
153
+ *project.load_form_from_session(current_session),
154
+ gr.update(),
155
+ '<span style="color: #c62828;">Error: Sample not found</span>',
156
+ "",
157
+ )
158
+
159
+ # Get scenario name for status message
160
+ scenario = samples.get_scenario_by_id(scenario_id)
161
+ name = scenario.name if scenario else scenario_id
162
+
163
+ # Load form values from new session
164
+ form_values = project.load_form_from_session(new_session)
165
+
166
+ return (
167
+ new_session, # updated session_state
168
+ *form_values, # 12 form values for Tab 1
169
+ gr.update(selected=0), # switch to Tab 1 (Gradio 6.x syntax)
170
+ f'<span style="color: #2e7d32;">Loaded sample: {name}</span>',
171
+ "", # reset dropdown to empty
172
+ )
173
+
174
+ sample_dropdown.change(
175
+ fn=handle_sample_load,
176
+ inputs=[sample_dropdown, session_state],
177
+ outputs=[
178
+ session_state,
179
+ tab1["project_name"],
180
+ tab1["address"],
181
+ tab1["city"],
182
+ tab1["state"],
183
+ tab1["zip_code"],
184
+ tab1["client_name"],
185
+ tab1["fire_date"],
186
+ tab1["assessment_date"],
187
+ tab1["facility_classification"],
188
+ tab1["construction_era"],
189
+ tab1["assessor_name"],
190
+ tab1["assessor_credentials"],
191
+ tabs,
192
+ sample_status,
193
+ sample_dropdown,
194
+ ],
195
+ )
196
+
197
  # Tab 1: Project Info
198
  tab1["validate_btn"].click(
199
  fn=project.validate_and_continue,
 
219
  ],
220
  )
221
 
222
+ # ZIP code validation on blur
223
+ tab1["zip_code"].blur(
224
+ fn=project.validate_zip_format,
225
+ inputs=[tab1["zip_code"]],
226
+ outputs=[tab1["zip_validation"]],
227
+ )
228
+
229
  # Tab 2: Building/Rooms
230
  tab2["add_room_btn"].click(
231
  fn=rooms.add_room,
 
235
  tab2["room_floor"],
236
  tab2["room_length"],
237
  tab2["room_width"],
238
+ tab2["room_height_preset"],
239
+ tab2["room_height_custom"],
240
  ],
241
  outputs=[
242
  session_state,
 
249
  tab2["room_floor"],
250
  tab2["room_length"],
251
  tab2["room_width"],
252
+ tab2["room_height_preset"],
253
+ tab2["room_height_custom"],
254
  ],
255
  )
256
 
257
+ # Show/hide custom height input based on preset selection
258
+ tab2["room_height_preset"].change(
259
+ fn=rooms.on_height_preset_change,
260
+ inputs=[tab2["room_height_preset"]],
261
+ outputs=[tab2["room_height_custom"]],
262
+ )
263
+
264
  tab2["clear_form_btn"].click(
265
+ fn=lambda: ("", None, None, None, None, None),
266
  outputs=[
267
  tab2["room_name"],
268
  tab2["room_floor"],
269
  tab2["room_length"],
270
  tab2["room_width"],
271
+ tab2["room_height_preset"],
272
+ tab2["room_height_custom"],
273
  ],
274
  )
275
 
 
310
  )
311
 
312
  tab2["back_btn"].click(
313
+ fn=lambda: gr.update(selected=0),
314
  outputs=[tabs],
315
  )
316
 
317
  # Tab 3: Images
 
 
 
 
 
 
 
 
 
318
  tab3["add_image_btn"].click(
319
  fn=images.add_image,
320
  inputs=[
 
375
  )
376
 
377
  tab3["back_btn"].click(
378
+ fn=lambda: gr.update(selected=1),
379
  outputs=[tabs],
380
  )
381
 
 
408
  )
409
 
410
  tab4["back_btn"].click(
411
+ fn=lambda: gr.update(selected=2),
412
  outputs=[tabs],
413
  )
414
 
415
  # Tab 5: Generate Results
 
 
 
 
 
 
 
 
 
416
  tab5["generate_btn"].click(
417
  fn=results.generate_assessment,
418
  inputs=[session_state],
 
444
  )
445
 
446
  tab5["back_btn"].click(
447
+ fn=lambda: gr.update(selected=3),
448
  outputs=[tabs],
449
  )
450
 
451
+ # --- Individual Tab Select Handlers ---
452
+ # Using Tab.select instead of Tabs.select because Tabs.select doesn't fire in Gradio 6.x
453
+ # See: https://github.com/gradio-app/gradio/issues/7189
454
 
455
+ # Tab 1 (Project): Load project form fields when selected
456
+ tab_project.select(
457
+ fn=project.load_form_from_session,
458
+ inputs=[session_state],
 
 
 
459
  outputs=[
460
  tab1["project_name"],
461
  tab1["address"],
 
472
  ],
473
  )
474
 
475
+ # Tab 2 (Rooms): Load room table and stats when selected
476
+ tab_rooms.select(
477
+ fn=rooms.load_from_session,
478
+ inputs=[session_state],
 
 
 
479
  outputs=[
480
  tab2["rooms_table"],
481
  tab2["room_count"],
 
484
  ],
485
  )
486
 
487
+ # Tab 3 (Images): Load gallery, count, and room dropdown when selected
488
+ def load_images_tab(session: SessionState):
489
+ """Load all images tab data."""
490
+ room_choices = images.update_room_choices(session)
491
+ gallery, count, warning = images.load_from_session(session)
492
+ return room_choices, gallery, count, warning
493
+
494
+ tab_images.select(
495
+ fn=load_images_tab,
496
+ inputs=[session_state],
497
  outputs=[
498
+ tab3["room_select"],
499
  tab3["images_gallery"],
500
  tab3["image_count"],
501
  tab3["resume_warning"],
502
  ],
503
  )
504
 
505
+ # Tab 4 (Observations): Load observation form fields when selected
506
+ tab_observations.select(
507
+ fn=observations.load_form_from_session,
508
+ inputs=[session_state],
 
 
 
509
  outputs=[
510
  tab4["smoke_odor"],
511
  tab4["odor_intensity"],
 
525
  ],
526
  )
527
 
528
+ # Tab 5 (Results): Check preflight status when selected
529
+ tab_results.select(
530
+ fn=results.check_preflight,
531
+ inputs=[session_state],
532
+ outputs=[tab5["preflight_status"]],
533
+ )
534
+
535
  return app
536
 
537
 
538
  def main():
539
  """Entry point for the application."""
540
+ logger.info("Starting FDAM AI Pipeline v4.0.1")
541
+ logger.info(f"Mock models: {settings.mock_models}")
542
+ logger.info(f"Log level: {settings.log_level}")
543
+ logger.info(f"Server: {settings.server_host}:{settings.server_port}")
544
 
545
  app = create_app()
546
  app.launch(
547
  server_name=settings.server_host,
548
  server_port=settings.server_port,
549
  share=False,
550
+ head=get_head_html(KEYBOARD_JS), # Inject localStorage + keyboard shortcuts
551
  )
552
 
553
 
config/inference.py CHANGED
@@ -1,24 +1,49 @@
1
- """Model inference configuration parameters."""
 
 
 
 
2
 
3
  from dataclasses import dataclass
4
 
5
 
6
  @dataclass
7
  class VisionInferenceConfig:
8
- """Configuration for vision model inference."""
 
 
 
9
 
10
  max_new_tokens: int = 4096
11
- temperature: float = 0.1
12
  top_p: float = 0.9
13
  do_sample: bool = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
 
16
  @dataclass
17
  class EmbeddingConfig:
18
- """Configuration for embedding model."""
 
 
 
19
 
20
- embedding_dimension: int = 768
21
- normalize: bool = True
22
 
23
 
24
  @dataclass
@@ -28,7 +53,21 @@ class RerankerConfig:
28
  top_k: int = 5
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  # Default configurations
32
  vision_config = VisionInferenceConfig()
 
33
  embedding_config = EmbeddingConfig()
34
  reranker_config = RerankerConfig()
 
 
1
+ """Model inference configuration parameters.
2
+
3
+ Configuration values aligned with official Qwen3-VL model recommendations
4
+ and FDAM Technical Spec requirements.
5
+ """
6
 
7
  from dataclasses import dataclass
8
 
9
 
10
  @dataclass
11
  class VisionInferenceConfig:
12
+ """Configuration for vision model inference.
13
+
14
+ Per FDAM Technical Spec Section 3 and Qwen3-VL-30B-A3B-Instruct model card.
15
+ """
16
 
17
  max_new_tokens: int = 4096
18
+ temperature: float = 0.1 # Low temperature for deterministic output
19
  top_p: float = 0.9
20
  do_sample: bool = True
21
+ repetition_penalty: float = 1.1 # Reduce repetition in generated text
22
+
23
+
24
+ @dataclass
25
+ class GenerationInferenceConfig:
26
+ """Configuration for document generation (SOW, sampling plans).
27
+
28
+ Per FDAM Technical Spec Section 3 - separate config for longer generation.
29
+ """
30
+
31
+ max_new_tokens: int = 8192
32
+ temperature: float = 0.2 # Slightly higher for more varied text
33
+ top_p: float = 0.95
34
+ do_sample: bool = True
35
+ repetition_penalty: float = 1.05
36
 
37
 
38
  @dataclass
39
  class EmbeddingConfig:
40
+ """Configuration for embedding model.
41
+
42
+ Per Qwen3-VL-Embedding-8B config.json: text_config.hidden_size = 4096
43
+ """
44
 
45
+ embedding_dimension: int = 4096 # Per Qwen3-VL-Embedding-8B hidden_size
46
+ normalize: bool = True # L2 normalization (per official implementation)
47
 
48
 
49
  @dataclass
 
53
  top_k: int = 5
54
 
55
 
56
+ @dataclass
57
+ class RAGConfig:
58
+ """Configuration for RAG retrieval pipeline.
59
+
60
+ Per FDAM Technical Spec Section 3.
61
+ """
62
+
63
+ top_k_retrieval: int = 10 # Initial retrieval count
64
+ top_k_rerank: int = 5 # Final results after reranking
65
+ similarity_threshold: float = 0.7 # Minimum similarity to include
66
+
67
+
68
  # Default configurations
69
  vision_config = VisionInferenceConfig()
70
+ generation_config = GenerationInferenceConfig()
71
  embedding_config = EmbeddingConfig()
72
  reranker_config = RerankerConfig()
73
+ rag_config = RAGConfig()
config/logging.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized logging configuration for FDAM AI Pipeline.
2
+
3
+ Provides structured logging for HuggingFace Spaces troubleshooting.
4
+ Set LOG_LEVEL=DEBUG for detailed output.
5
+ """
6
+
7
+ import logging
8
+ import sys
9
+ from typing import Literal
10
+
11
+
12
+ LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
13
+
14
+
15
+ def setup_logging(level: LogLevel = "INFO") -> None:
16
+ """Configure structured logging for FDAM Pipeline.
17
+
18
+ Args:
19
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
20
+ DEBUG provides detailed inference timing and RAG scores.
21
+ INFO provides pipeline stage progress.
22
+ WARNING and above for production.
23
+ """
24
+ log_format = "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s"
25
+ date_format = "%Y-%m-%d %H:%M:%S"
26
+
27
+ # Configure root logger
28
+ logging.basicConfig(
29
+ level=getattr(logging, level.upper(), logging.INFO),
30
+ format=log_format,
31
+ datefmt=date_format,
32
+ handlers=[logging.StreamHandler(sys.stdout)],
33
+ force=True, # Override any existing config
34
+ )
35
+
36
+ # Reduce noise from third-party libraries
37
+ logging.getLogger("chromadb").setLevel(logging.WARNING)
38
+ logging.getLogger("transformers").setLevel(logging.WARNING)
39
+ logging.getLogger("gradio").setLevel(logging.WARNING)
40
+ logging.getLogger("httpx").setLevel(logging.WARNING)
41
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
42
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
43
+ logging.getLogger("PIL").setLevel(logging.WARNING)
44
+
45
+ # Log the logging configuration itself
46
+ logger = logging.getLogger(__name__)
47
+ logger.info(f"Logging initialized at {level} level")
48
+
49
+
50
+ def get_logger(name: str) -> logging.Logger:
51
+ """Get a logger with the given name.
52
+
53
+ Convenience function for consistent logger creation.
54
+
55
+ Args:
56
+ name: Logger name (typically __name__ of the calling module).
57
+
58
+ Returns:
59
+ Configured logger instance.
60
+ """
61
+ return logging.getLogger(name)
config/settings.py CHANGED
@@ -10,6 +10,9 @@ class Settings(BaseSettings):
10
  # Environment
11
  environment: Literal["development", "production"] = "development"
12
 
 
 
 
13
  # Model loading - set MOCK_MODELS=true for local dev on RTX 4090
14
  mock_models: bool = True
15
 
 
10
  # Environment
11
  environment: Literal["development", "production"] = "development"
12
 
13
+ # Logging - set LOG_LEVEL=DEBUG for detailed troubleshooting on HF Spaces
14
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
15
+
16
  # Model loading - set MOCK_MODELS=true for local dev on RTX 4090
17
  mock_models: bool = True
18
 
models/loader.py CHANGED
@@ -1,9 +1,13 @@
1
  """Model loading with mock/real switching based on environment."""
2
 
 
 
3
  from typing import Union
4
 
5
  from config.settings import settings
6
 
 
 
7
  # Type alias for model stack
8
  ModelStack = Union["MockModelStack", "RealModelStack"] # noqa: F821
9
 
@@ -13,21 +17,37 @@ _model_stack: ModelStack | None = None
13
 
14
  def get_model_stack() -> ModelStack:
15
  """Get model stack based on environment configuration."""
 
 
16
  if settings.mock_models:
 
17
  from models.mock import MockModelStack
18
 
19
- return MockModelStack().load_all()
 
 
 
20
  else:
 
 
 
 
21
  from models.real import RealModelStack
22
 
23
- return RealModelStack().load_all()
 
 
 
24
 
25
 
26
  def get_models() -> ModelStack:
27
  """Get or create the singleton model stack."""
28
  global _model_stack
29
  if _model_stack is None:
 
30
  _model_stack = get_model_stack()
 
 
31
  return _model_stack
32
 
33
 
 
1
  """Model loading with mock/real switching based on environment."""
2
 
3
+ import logging
4
+ import time
5
  from typing import Union
6
 
7
  from config.settings import settings
8
 
9
+ logger = logging.getLogger(__name__)
10
+
11
  # Type alias for model stack
12
  ModelStack = Union["MockModelStack", "RealModelStack"] # noqa: F821
13
 
 
17
 
18
  def get_model_stack() -> ModelStack:
19
  """Get model stack based on environment configuration."""
20
+ start_time = time.time()
21
+
22
  if settings.mock_models:
23
+ logger.info("Loading MOCK model stack (development mode)")
24
  from models.mock import MockModelStack
25
 
26
+ stack = MockModelStack().load_all()
27
+ elapsed = time.time() - start_time
28
+ logger.info(f"Mock model stack loaded in {elapsed:.2f}s")
29
+ return stack
30
  else:
31
+ logger.info("Loading REAL model stack (production mode)")
32
+ logger.info(f"Vision model: {settings.vision_model}")
33
+ logger.info(f"Embedding model: {settings.embedding_model}")
34
+ logger.info(f"Reranker model: {settings.reranker_model}")
35
  from models.real import RealModelStack
36
 
37
+ stack = RealModelStack().load_all()
38
+ elapsed = time.time() - start_time
39
+ logger.info(f"Real model stack loaded in {elapsed:.2f}s")
40
+ return stack
41
 
42
 
43
  def get_models() -> ModelStack:
44
  """Get or create the singleton model stack."""
45
  global _model_stack
46
  if _model_stack is None:
47
+ logger.debug("Model stack not initialized, creating new stack")
48
  _model_stack = get_model_stack()
49
+ else:
50
+ logger.debug("Returning cached model stack")
51
  return _model_stack
52
 
53
 
models/mock.py CHANGED
@@ -1,9 +1,12 @@
1
  """Mock model implementations for local development on RTX 4090."""
2
 
 
3
  import random
4
  from typing import Any
5
  from PIL import Image
6
 
 
 
7
 
8
  class MockVisionModel:
9
  """Mock vision model that returns realistic JSON responses."""
@@ -27,8 +30,10 @@ class MockVisionModel:
27
 
28
  def analyze_image(self, image: Image.Image, context: str = "") -> dict[str, Any]:
29
  """Return mock vision analysis matching the spec schema."""
 
30
  selected_zone = random.choice(self.ZONES)
31
  selected_condition = random.choice(self.CONDITIONS)
 
32
 
33
  # Generate 2-4 random materials
34
  num_materials = random.randint(2, 4)
@@ -98,17 +103,33 @@ class MockVisionModel:
98
 
99
 
100
  class MockEmbeddingModel:
101
- """Mock embedding model that returns random vectors."""
 
 
 
 
102
 
103
- def __init__(self, dimension: int = 768):
 
104
  self.dimension = dimension
105
 
106
  def embed(self, text: str) -> list[float]:
107
- """Return mock embedding vector."""
 
 
 
 
 
108
  # Use hash of text for reproducibility
109
  random.seed(hash(text) % (2**32))
110
  embedding = [random.uniform(-1, 1) for _ in range(self.dimension)]
111
  random.seed() # Reset seed
 
 
 
 
 
 
112
  return embedding
113
 
114
  def embed_batch(self, texts: list[str]) -> list[list[float]]:
@@ -117,21 +138,52 @@ class MockEmbeddingModel:
117
 
118
 
119
  class MockRerankerModel:
120
- """Mock reranker that returns random scores."""
 
 
 
121
 
122
  def rerank(self, query: str, documents: list[str]) -> list[float]:
123
- """Return mock reranking scores."""
124
- # Higher scores for documents that share more words with query
 
 
 
 
125
  scores = []
126
  query_words = set(query.lower().split())
 
127
  for doc in documents:
128
  doc_words = set(doc.lower().split())
129
- overlap = len(query_words & doc_words)
130
- base_score = overlap / max(len(query_words), 1)
131
- noise = random.uniform(-0.1, 0.1)
132
- scores.append(min(1.0, max(0.0, base_score + noise)))
 
 
 
 
 
 
 
 
 
 
 
 
133
  return scores
134
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
  class MockModelStack:
137
  """Mock model stack for local development."""
@@ -144,12 +196,12 @@ class MockModelStack:
144
 
145
  def load_all(self) -> "MockModelStack":
146
  """Simulate model loading."""
147
- print("[MOCK] Loading mock models for local development...")
148
- print("[MOCK] Vision model: MockVisionModel")
149
- print("[MOCK] Embedding model: MockEmbeddingModel")
150
- print("[MOCK] Reranker model: MockRerankerModel")
151
  self.loaded = True
152
- print("[MOCK] All mock models loaded successfully.")
153
  return self
154
 
155
  def is_loaded(self) -> bool:
 
1
  """Mock model implementations for local development on RTX 4090."""
2
 
3
+ import logging
4
  import random
5
  from typing import Any
6
  from PIL import Image
7
 
8
+ logger = logging.getLogger(__name__)
9
+
10
 
11
  class MockVisionModel:
12
  """Mock vision model that returns realistic JSON responses."""
 
30
 
31
  def analyze_image(self, image: Image.Image, context: str = "") -> dict[str, Any]:
32
  """Return mock vision analysis matching the spec schema."""
33
+ logger.debug(f"Mock vision analysis (context: {len(context)} chars)")
34
  selected_zone = random.choice(self.ZONES)
35
  selected_condition = random.choice(self.CONDITIONS)
36
+ logger.info(f"Mock vision result: zone={selected_zone}, condition={selected_condition}")
37
 
38
  # Generate 2-4 random materials
39
  num_materials = random.randint(2, 4)
 
103
 
104
 
105
  class MockEmbeddingModel:
106
+ """Mock embedding model that returns deterministic vectors.
107
+
108
+ Dimension matches Qwen3-VL-Embedding-8B (4096-dim).
109
+ Uses last-token pooling concept with L2 normalization.
110
+ """
111
 
112
+ def __init__(self, dimension: int = 4096):
113
+ """Initialize with dimension matching real Qwen3-VL-Embedding-8B model."""
114
  self.dimension = dimension
115
 
116
  def embed(self, text: str) -> list[float]:
117
+ """Return mock embedding vector (4096-dim, L2 normalized).
118
+
119
+ Uses hash of text for reproducibility, simulating last-token pooling.
120
+ """
121
+ import math
122
+
123
  # Use hash of text for reproducibility
124
  random.seed(hash(text) % (2**32))
125
  embedding = [random.uniform(-1, 1) for _ in range(self.dimension)]
126
  random.seed() # Reset seed
127
+
128
+ # L2 normalize (matching real model behavior)
129
+ norm = math.sqrt(sum(x * x for x in embedding))
130
+ if norm > 0:
131
+ embedding = [x / norm for x in embedding]
132
+
133
  return embedding
134
 
135
  def embed_batch(self, texts: list[str]) -> list[list[float]]:
 
138
 
139
 
140
  class MockRerankerModel:
141
+ """Mock reranker that returns realistic relevance scores.
142
+
143
+ Simulates Qwen3-VL-Reranker behavior with 0-1 sigmoid-like scores.
144
+ """
145
 
146
  def rerank(self, query: str, documents: list[str]) -> list[float]:
147
+ """Return mock reranking scores (0-1 range, higher = more relevant).
148
+
149
+ Uses word overlap + sigmoid-like transformation to mimic real behavior.
150
+ """
151
+ import math
152
+
153
  scores = []
154
  query_words = set(query.lower().split())
155
+
156
  for doc in documents:
157
  doc_words = set(doc.lower().split())
158
+ # Calculate Jaccard-like overlap
159
+ if len(query_words) > 0:
160
+ overlap = len(query_words & doc_words)
161
+ # Scale to get a raw score
162
+ raw_score = overlap / max(len(query_words), 1) * 3 - 1.5
163
+ else:
164
+ raw_score = 0
165
+
166
+ # Add small random noise
167
+ noise = random.uniform(-0.3, 0.3)
168
+ raw_score += noise
169
+
170
+ # Apply sigmoid to get 0-1 range (mimics real model behavior)
171
+ score = 1 / (1 + math.exp(-raw_score))
172
+ scores.append(score)
173
+
174
  return scores
175
 
176
+ def rerank_with_indices(
177
+ self, query: str, documents: list[str], top_k: int = None
178
+ ) -> list[tuple[int, float]]:
179
+ """Rerank and return sorted (index, score) tuples."""
180
+ scores = self.rerank(query, documents)
181
+ indexed_scores = list(enumerate(scores))
182
+ indexed_scores.sort(key=lambda x: x[1], reverse=True)
183
+ if top_k is not None:
184
+ indexed_scores = indexed_scores[:top_k]
185
+ return indexed_scores
186
+
187
 
188
  class MockModelStack:
189
  """Mock model stack for local development."""
 
196
 
197
  def load_all(self) -> "MockModelStack":
198
  """Simulate model loading."""
199
+ logger.info("Loading mock models for local development")
200
+ logger.debug(" Vision model: MockVisionModel")
201
+ logger.debug(" Embedding model: MockEmbeddingModel")
202
+ logger.debug(" Reranker model: MockRerankerModel")
203
  self.loaded = True
204
+ logger.info("All mock models loaded successfully")
205
  return self
206
 
207
  def is_loaded(self) -> bool:
models/real.py CHANGED
@@ -7,10 +7,12 @@ Requires ~90GB VRAM (4xL4 with 96GB total).
7
  import json
8
  import logging
9
  import re
 
10
  import torch
11
  from typing import Any
12
  from PIL import Image
13
 
 
14
  from config.settings import settings
15
 
16
  logger = logging.getLogger(__name__)
@@ -28,10 +30,18 @@ class RealModelStack:
28
  """Load all models with device_map='auto' for multi-GPU distribution."""
29
  from transformers import AutoModel, AutoProcessor
30
 
31
- print(f"Loading models on {'cuda' if torch.cuda.is_available() else 'cpu'}...")
 
 
 
 
 
 
 
32
 
33
  # Vision model (~58GB in BF16)
34
- print(f"Loading vision model: {settings.vision_model}...")
 
35
  try:
36
  from transformers import Qwen3VLMoeForConditionalGeneration
37
 
@@ -45,9 +55,10 @@ class RealModelStack:
45
  settings.vision_model,
46
  trust_remote_code=True,
47
  )
 
48
  except Exception as e:
49
- print(f"Failed to load 30B vision model: {e}")
50
- print(f"Falling back to {settings.vision_model_fallback}...")
51
  self.models["vision"] = Qwen3VLMoeForConditionalGeneration.from_pretrained(
52
  settings.vision_model_fallback,
53
  torch_dtype=torch.bfloat16,
@@ -58,9 +69,11 @@ class RealModelStack:
58
  settings.vision_model_fallback,
59
  trust_remote_code=True,
60
  )
 
61
 
62
  # Embedding model (~16GB in BF16)
63
- print(f"Loading embedding model: {settings.embedding_model}...")
 
64
  self.models["embedding"] = AutoModel.from_pretrained(
65
  settings.embedding_model,
66
  torch_dtype=torch.bfloat16,
@@ -71,9 +84,11 @@ class RealModelStack:
71
  settings.embedding_model,
72
  trust_remote_code=True,
73
  )
 
74
 
75
  # Reranker model (~16GB in BF16)
76
- print(f"Loading reranker model: {settings.reranker_model}...")
 
77
  self.models["reranker"] = AutoModel.from_pretrained(
78
  settings.reranker_model,
79
  torch_dtype=torch.bfloat16,
@@ -84,9 +99,10 @@ class RealModelStack:
84
  settings.reranker_model,
85
  trust_remote_code=True,
86
  )
 
87
 
88
  self.loaded = True
89
- print("All models loaded successfully.")
90
  return self
91
 
92
  def is_loaded(self) -> bool:
@@ -97,7 +113,43 @@ class RealModelStack:
97
  class RealVisionModel:
98
  """Wrapper for real vision model inference."""
99
 
100
- # Analysis prompt template for FDAM fire damage assessment
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  ANALYSIS_PROMPT = """Analyze this fire damage image and return a JSON response with the following structure:
102
 
103
  {
@@ -140,18 +192,6 @@ class RealVisionModel:
140
  "flags_for_review": ["any items requiring human review"]
141
  }
142
 
143
- Zone definitions:
144
- - burn: Direct fire involvement, visible charring, structural damage
145
- - near-field: Adjacent to burn zone, heavy smoke/heat exposure, discoloration
146
- - far-field: Smoke migration only, light deposits, no structural damage
147
-
148
- Condition definitions:
149
- - background: No visible contamination
150
- - light: Faint discoloration, minimal deposits
151
- - moderate: Visible film/deposits, surface color altered
152
- - heavy: Thick deposits, surface texture obscured
153
- - structural-damage: Physical damage requiring repair before cleaning
154
-
155
  IMPORTANT: Return ONLY valid JSON, no additional text."""
156
 
157
  def __init__(self, model, processor):
@@ -160,19 +200,26 @@ IMPORTANT: Return ONLY valid JSON, no additional text."""
160
 
161
  def analyze_image(self, image: Image.Image, context: str = "") -> dict[str, Any]:
162
  """Analyze an image and return structured results."""
 
 
 
163
  try:
164
  from qwen_vl_utils import process_vision_info
165
  except ImportError:
166
  logger.warning("qwen_vl_utils not available, using basic processing")
167
  process_vision_info = None
168
 
169
- # Build the analysis prompt
170
  prompt = self.ANALYSIS_PROMPT
171
  if context:
172
  prompt = f"Context: {context}\n\n{prompt}"
173
 
174
- # Prepare messages in Qwen-VL format
175
  messages = [
 
 
 
 
176
  {
177
  "role": "user",
178
  "content": [
@@ -210,23 +257,57 @@ IMPORTANT: Return ONLY valid JSON, no additional text."""
210
  # Move inputs to model device
211
  inputs = {k: v.to(self.model.device) for k, v in inputs.items()}
212
 
213
- # Generate response
 
 
 
 
 
214
  with torch.no_grad():
215
- outputs = self.model.generate(
216
- **inputs,
217
- max_new_tokens=2048,
218
- do_sample=False,
219
- temperature=None,
220
- top_p=None,
221
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
  # Decode response
224
  response_text = self.processor.decode(
225
  outputs[0], skip_special_tokens=True
226
  )
 
227
 
228
  # Parse JSON from response
229
- return self._parse_vision_response(response_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  except Exception as e:
232
  logger.error(f"Vision analysis failed: {e}")
@@ -287,14 +368,36 @@ IMPORTANT: Return ONLY valid JSON, no additional text."""
287
 
288
 
289
  class RealEmbeddingModel:
290
- """Wrapper for real embedding model inference."""
 
 
 
 
291
 
292
  def __init__(self, model, processor):
293
  self.model = model
294
  self.processor = processor
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  def embed(self, text: str) -> list[float]:
297
- """Generate embedding for text using mean pooling."""
 
 
 
 
298
  try:
299
  # Tokenize input
300
  inputs = self.processor(
@@ -312,31 +415,23 @@ class RealEmbeddingModel:
312
  with torch.no_grad():
313
  outputs = self.model(**inputs)
314
 
315
- # Use mean pooling over sequence dimension
316
  # outputs.last_hidden_state shape: (batch, seq_len, hidden_dim)
317
  attention_mask = inputs.get("attention_mask")
318
  if attention_mask is not None:
319
- # Mask-weighted mean pooling
320
- mask_expanded = attention_mask.unsqueeze(-1).expand(
321
- outputs.last_hidden_state.size()
322
- ).float()
323
- sum_embeddings = torch.sum(
324
- outputs.last_hidden_state * mask_expanded, dim=1
325
- )
326
- sum_mask = torch.clamp(mask_expanded.sum(dim=1), min=1e-9)
327
- embeddings = sum_embeddings / sum_mask
328
  else:
329
- # Simple mean if no attention mask
330
- embeddings = outputs.last_hidden_state.mean(dim=1)
331
 
332
- # Normalize
333
- embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)
334
 
335
  return embeddings[0].cpu().tolist()
336
 
337
  except Exception as e:
338
  logger.error(f"Embedding generation failed: {e}")
339
- # Return zero vector as fallback
340
  hidden_size = getattr(self.model.config, "hidden_size", 4096)
341
  return [0.0] * hidden_size
342
 
@@ -346,16 +441,69 @@ class RealEmbeddingModel:
346
 
347
 
348
  class RealRerankerModel:
349
- """Wrapper for real reranker model inference."""
 
 
 
 
 
 
 
 
350
 
351
  def __init__(self, model, processor):
352
  self.model = model
353
  self.processor = processor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  def rerank(self, query: str, documents: list[str]) -> list[float]:
356
  """Rerank documents by relevance to query.
357
 
358
- Returns a list of relevance scores for each document.
359
  Higher scores indicate more relevant documents.
360
  """
361
  if not documents:
@@ -373,13 +521,13 @@ class RealRerankerModel:
373
  return scores
374
 
375
  def _score_pair(self, query: str, document: str) -> float:
376
- """Score a single query-document pair."""
377
- # Format as query-document pair for cross-encoder
378
  # Truncate document if too long
379
  max_doc_len = 400
380
  if len(document) > max_doc_len:
381
  document = document[:max_doc_len] + "..."
382
 
 
383
  pair_text = f"Query: {query}\n\nDocument: {document}"
384
 
385
  try:
@@ -397,16 +545,19 @@ class RealRerankerModel:
397
  with torch.no_grad():
398
  outputs = self.model(**inputs)
399
 
400
- # Use CLS token representation for scoring
401
- # Take mean of last hidden state as a simple relevance score
402
- cls_embedding = outputs.last_hidden_state[:, 0, :]
403
 
404
- # Normalize and take mean as score
405
- score = cls_embedding.norm(dim=-1).mean().item()
406
-
407
- # Normalize score to 0-1 range (approximate)
408
- # This is heuristic; actual reranker models have specific score heads
409
- score = min(1.0, max(0.0, score / 100.0))
 
 
 
410
 
411
  return score
412
 
 
7
  import json
8
  import logging
9
  import re
10
+ import time
11
  import torch
12
  from typing import Any
13
  from PIL import Image
14
 
15
+ from config.inference import vision_config
16
  from config.settings import settings
17
 
18
  logger = logging.getLogger(__name__)
 
30
  """Load all models with device_map='auto' for multi-GPU distribution."""
31
  from transformers import AutoModel, AutoProcessor
32
 
33
+ device_type = 'cuda' if torch.cuda.is_available() else 'cpu'
34
+ logger.info(f"Loading models on {device_type}")
35
+ if torch.cuda.is_available():
36
+ gpu_count = torch.cuda.device_count()
37
+ logger.info(f"CUDA devices available: {gpu_count}")
38
+ for i in range(gpu_count):
39
+ mem_gb = torch.cuda.get_device_properties(i).total_memory / (1024**3)
40
+ logger.info(f" GPU {i}: {torch.cuda.get_device_name(i)} ({mem_gb:.1f} GB)")
41
 
42
  # Vision model (~58GB in BF16)
43
+ logger.info(f"Loading vision model: {settings.vision_model}")
44
+ vision_start = time.time()
45
  try:
46
  from transformers import Qwen3VLMoeForConditionalGeneration
47
 
 
55
  settings.vision_model,
56
  trust_remote_code=True,
57
  )
58
+ logger.info(f"Vision model loaded in {time.time() - vision_start:.2f}s")
59
  except Exception as e:
60
+ logger.warning(f"Failed to load 30B vision model: {e}")
61
+ logger.info(f"Falling back to {settings.vision_model_fallback}")
62
  self.models["vision"] = Qwen3VLMoeForConditionalGeneration.from_pretrained(
63
  settings.vision_model_fallback,
64
  torch_dtype=torch.bfloat16,
 
69
  settings.vision_model_fallback,
70
  trust_remote_code=True,
71
  )
72
+ logger.info(f"Fallback vision model loaded in {time.time() - vision_start:.2f}s")
73
 
74
  # Embedding model (~16GB in BF16)
75
+ logger.info(f"Loading embedding model: {settings.embedding_model}")
76
+ embed_start = time.time()
77
  self.models["embedding"] = AutoModel.from_pretrained(
78
  settings.embedding_model,
79
  torch_dtype=torch.bfloat16,
 
84
  settings.embedding_model,
85
  trust_remote_code=True,
86
  )
87
+ logger.info(f"Embedding model loaded in {time.time() - embed_start:.2f}s")
88
 
89
  # Reranker model (~16GB in BF16)
90
+ logger.info(f"Loading reranker model: {settings.reranker_model}")
91
+ reranker_start = time.time()
92
  self.models["reranker"] = AutoModel.from_pretrained(
93
  settings.reranker_model,
94
  torch_dtype=torch.bfloat16,
 
99
  settings.reranker_model,
100
  trust_remote_code=True,
101
  )
102
+ logger.info(f"Reranker model loaded in {time.time() - reranker_start:.2f}s")
103
 
104
  self.loaded = True
105
+ logger.info("All models loaded successfully")
106
  return self
107
 
108
  def is_loaded(self) -> bool:
 
113
  class RealVisionModel:
114
  """Wrapper for real vision model inference."""
115
 
116
+ # System prompt for FDAM fire damage assessment (per Technical Spec Section 7)
117
+ VISION_SYSTEM_PROMPT = """You are an expert industrial hygienist analyzing fire damage images for the FDAM (Fire Damage Assessment Methodology) framework.
118
+
119
+ ## Your Task
120
+ Analyze the provided image and extract structured information about fire damage, materials, and conditions.
121
+
122
+ ## Zone Classification Criteria
123
+ - **Burn Zone**: Direct fire involvement. Look for structural char, complete combustion, exposed/damaged structural elements.
124
+ - **Near-Field**: Adjacent to burn zone with heavy smoke/heat exposure. Look for heavy soot deposits, heat damage (warping, discoloration), strong visible contamination.
125
+ - **Far-Field**: Smoke migration without direct heat exposure. Look for light to moderate deposits, discoloration, no structural damage.
126
+
127
+ ## Condition Assessment Criteria
128
+ - **Background**: No visible contamination; surfaces appear normal/clean.
129
+ - **Light**: Faint discoloration; minimal visible deposits; would show faint marks on white wipe test.
130
+ - **Moderate**: Visible film or deposits; clear contamination; surface color noticeably altered.
131
+ - **Heavy**: Thick deposits; surface texture obscured; heavy coating visible.
132
+ - **Structural Damage**: Physical damage requiring repair before cleaning (charring, warping, holes, collapse).
133
+
134
+ ## Material Identification
135
+ Identify visible materials and categorize as:
136
+ - **Non-porous**: steel, concrete, glass, metal, CMU (concrete masonry unit)
137
+ - **Semi-porous**: painted drywall, sealed wood
138
+ - **Porous**: unpainted drywall, carpet, insulation, acoustic tile, upholstery
139
+ - **HVAC**: rigid ductwork, flexible ductwork
140
+
141
+ ## Combustion Particle Visual Indicators
142
+ - **Soot**: Black/dark gray coating with oily/sticky appearance; fine uniform texture; often creates "shadow" patterns
143
+ - **Char**: Black angular fragments; visible wood grain or fibrous structure; larger particles
144
+ - **Ash**: Gray/white powdery residue; crystalline appearance; often found with char
145
+
146
+ ## Important Notes
147
+ - This is VISUAL assessment only - definitive particle identification requires laboratory analysis
148
+ - When uncertain between two classifications, note both with relative confidence
149
+ - Flag any areas that require professional on-site verification
150
+ - Note any potential access issues visible in the image"""
151
+
152
+ # Analysis prompt template with JSON schema
153
  ANALYSIS_PROMPT = """Analyze this fire damage image and return a JSON response with the following structure:
154
 
155
  {
 
192
  "flags_for_review": ["any items requiring human review"]
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  IMPORTANT: Return ONLY valid JSON, no additional text."""
196
 
197
  def __init__(self, model, processor):
 
200
 
201
  def analyze_image(self, image: Image.Image, context: str = "") -> dict[str, Any]:
202
  """Analyze an image and return structured results."""
203
+ start_time = time.time()
204
+ logger.debug(f"Starting vision analysis (context: {len(context)} chars)")
205
+
206
  try:
207
  from qwen_vl_utils import process_vision_info
208
  except ImportError:
209
  logger.warning("qwen_vl_utils not available, using basic processing")
210
  process_vision_info = None
211
 
212
+ # Build the analysis prompt with context
213
  prompt = self.ANALYSIS_PROMPT
214
  if context:
215
  prompt = f"Context: {context}\n\n{prompt}"
216
 
217
+ # Prepare messages in Qwen-VL format with system prompt
218
  messages = [
219
+ {
220
+ "role": "system",
221
+ "content": self.VISION_SYSTEM_PROMPT,
222
+ },
223
  {
224
  "role": "user",
225
  "content": [
 
257
  # Move inputs to model device
258
  inputs = {k: v.to(self.model.device) for k, v in inputs.items()}
259
 
260
+ # Log inference config being used
261
+ logger.debug(f"Vision inference config: max_new_tokens={vision_config.max_new_tokens}, "
262
+ f"do_sample={vision_config.do_sample}, temp={vision_config.temperature}")
263
+
264
+ # Generate response using config values
265
+ inference_start = time.time()
266
  with torch.no_grad():
267
+ if vision_config.do_sample:
268
+ outputs = self.model.generate(
269
+ **inputs,
270
+ max_new_tokens=vision_config.max_new_tokens,
271
+ do_sample=True,
272
+ temperature=vision_config.temperature,
273
+ top_p=vision_config.top_p,
274
+ repetition_penalty=vision_config.repetition_penalty,
275
+ )
276
+ else:
277
+ # Deterministic mode (no sampling)
278
+ outputs = self.model.generate(
279
+ **inputs,
280
+ max_new_tokens=vision_config.max_new_tokens,
281
+ do_sample=False,
282
+ temperature=None,
283
+ top_p=None,
284
+ repetition_penalty=vision_config.repetition_penalty,
285
+ )
286
+
287
+ inference_time = time.time() - inference_start
288
+ logger.debug(f"Vision inference completed in {inference_time:.2f}s")
289
 
290
  # Decode response
291
  response_text = self.processor.decode(
292
  outputs[0], skip_special_tokens=True
293
  )
294
+ logger.debug(f"Response length: {len(response_text)} chars")
295
 
296
  # Parse JSON from response
297
+ result = self._parse_vision_response(response_text)
298
+
299
+ # Log result summary
300
+ total_time = time.time() - start_time
301
+ zone = result.get("zone", {}).get("classification", "unknown")
302
+ zone_conf = result.get("zone", {}).get("confidence", 0)
303
+ condition = result.get("condition", {}).get("level", "unknown")
304
+ condition_conf = result.get("condition", {}).get("confidence", 0)
305
+ num_materials = len(result.get("materials", []))
306
+ logger.info(f"Vision analysis complete in {total_time:.2f}s: "
307
+ f"zone={zone} ({zone_conf:.2f}), condition={condition} ({condition_conf:.2f}), "
308
+ f"materials={num_materials}")
309
+
310
+ return result
311
 
312
  except Exception as e:
313
  logger.error(f"Vision analysis failed: {e}")
 
368
 
369
 
370
  class RealEmbeddingModel:
371
+ """Wrapper for real embedding model inference.
372
+
373
+ Uses last-token pooling per official Qwen3-VL-Embedding implementation:
374
+ https://github.com/QwenLM/Qwen3-VL-Embedding
375
+ """
376
 
377
  def __init__(self, model, processor):
378
  self.model = model
379
  self.processor = processor
380
 
381
+ @staticmethod
382
+ def _pooling_last(hidden_state: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
383
+ """Extract the last valid token's hidden state based on attention mask.
384
+
385
+ This is the official pooling method from Qwen3-VL-Embedding.
386
+ It finds the last position where attention_mask == 1 and extracts that token.
387
+ """
388
+ # Flip attention mask to find last 1 position
389
+ flipped_tensor = attention_mask.flip(dims=[1])
390
+ last_one_positions = flipped_tensor.argmax(dim=1)
391
+ col = attention_mask.shape[1] - last_one_positions - 1
392
+ row = torch.arange(hidden_state.shape[0], device=hidden_state.device)
393
+ return hidden_state[row, col]
394
+
395
  def embed(self, text: str) -> list[float]:
396
+ """Generate embedding for text using last-token pooling.
397
+
398
+ Per Qwen3-VL-Embedding: extracts the hidden state of the last valid token,
399
+ then applies L2 normalization.
400
+ """
401
  try:
402
  # Tokenize input
403
  inputs = self.processor(
 
415
  with torch.no_grad():
416
  outputs = self.model(**inputs)
417
 
418
+ # Use last-token pooling (official Qwen3-VL-Embedding method)
419
  # outputs.last_hidden_state shape: (batch, seq_len, hidden_dim)
420
  attention_mask = inputs.get("attention_mask")
421
  if attention_mask is not None:
422
+ embeddings = self._pooling_last(outputs.last_hidden_state, attention_mask)
 
 
 
 
 
 
 
 
423
  else:
424
+ # Fallback: use last token if no attention mask
425
+ embeddings = outputs.last_hidden_state[:, -1, :]
426
 
427
+ # L2 normalize (per official implementation)
428
+ embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=-1)
429
 
430
  return embeddings[0].cpu().tolist()
431
 
432
  except Exception as e:
433
  logger.error(f"Embedding generation failed: {e}")
434
+ # Return zero vector as fallback (4096-dim per Qwen3-VL-Embedding-8B)
435
  hidden_size = getattr(self.model.config, "hidden_size", 4096)
436
  return [0.0] * hidden_size
437
 
 
441
 
442
 
443
  class RealRerankerModel:
444
+ """Wrapper for real reranker model inference.
445
+
446
+ Uses the official Qwen3-VL-Reranker scoring method:
447
+ - Extracts "yes" and "no" token weights from the LM head
448
+ - Creates a binary linear layer: weight = yes_weight - no_weight
449
+ - Scores = sigmoid(linear(last_token_hidden_state))
450
+
451
+ Reference: https://github.com/QwenLM/Qwen3-VL-Embedding
452
+ """
453
 
454
  def __init__(self, model, processor):
455
  self.model = model
456
  self.processor = processor
457
+ self.score_linear = None
458
+ self._initialize_score_linear()
459
+
460
+ def _initialize_score_linear(self):
461
+ """Initialize the binary scoring linear layer from LM head weights.
462
+
463
+ Per Qwen3-VL-Reranker: the scoring layer uses the difference between
464
+ "yes" and "no" token embeddings from the language model head.
465
+ """
466
+ try:
467
+ # Get tokenizer vocab to find yes/no token IDs
468
+ tokenizer = self.processor.tokenizer if hasattr(self.processor, 'tokenizer') else self.processor
469
+ vocab = tokenizer.get_vocab()
470
+
471
+ # Find yes/no token IDs
472
+ token_yes_id = vocab.get("yes")
473
+ token_no_id = vocab.get("no")
474
+
475
+ if token_yes_id is None or token_no_id is None:
476
+ logger.warning("Could not find 'yes'/'no' tokens in vocab, using fallback scoring")
477
+ return
478
+
479
+ # Get LM head weights
480
+ if not hasattr(self.model, 'lm_head'):
481
+ logger.warning("Model does not have lm_head, using fallback scoring")
482
+ return
483
+
484
+ lm_head_weights = self.model.lm_head.weight.data
485
+
486
+ # Extract yes/no weights
487
+ weight_yes = lm_head_weights[token_yes_id]
488
+ weight_no = lm_head_weights[token_no_id]
489
+
490
+ # Create binary linear layer: weight = yes - no
491
+ hidden_size = weight_yes.shape[0]
492
+ self.score_linear = torch.nn.Linear(hidden_size, 1, bias=False)
493
+ self.score_linear.weight.data[0] = weight_yes - weight_no
494
+ self.score_linear = self.score_linear.to(self.model.device)
495
+ self.score_linear.eval()
496
+
497
+ logger.info(f"Initialized reranker score linear from yes/no LM head weights (hidden_size={hidden_size})")
498
+
499
+ except Exception as e:
500
+ logger.warning(f"Failed to initialize score linear from LM head: {e}, using fallback scoring")
501
+ self.score_linear = None
502
 
503
  def rerank(self, query: str, documents: list[str]) -> list[float]:
504
  """Rerank documents by relevance to query.
505
 
506
+ Returns a list of relevance scores (0-1) for each document.
507
  Higher scores indicate more relevant documents.
508
  """
509
  if not documents:
 
521
  return scores
522
 
523
  def _score_pair(self, query: str, document: str) -> float:
524
+ """Score a single query-document pair using official Qwen3-VL-Reranker method."""
 
525
  # Truncate document if too long
526
  max_doc_len = 400
527
  if len(document) > max_doc_len:
528
  document = document[:max_doc_len] + "..."
529
 
530
+ # Format as query-document pair
531
  pair_text = f"Query: {query}\n\nDocument: {document}"
532
 
533
  try:
 
545
  with torch.no_grad():
546
  outputs = self.model(**inputs)
547
 
548
+ # Use LAST token hidden state (not CLS/first token)
549
+ # Per official implementation: last_hidden_state[:, -1]
550
+ last_token_hidden = outputs.last_hidden_state[:, -1, :]
551
 
552
+ if self.score_linear is not None:
553
+ # Official scoring: linear(last_token) -> sigmoid
554
+ raw_score = self.score_linear(last_token_hidden)
555
+ score = torch.sigmoid(raw_score).squeeze(-1).item()
556
+ else:
557
+ # Fallback: use L2 norm with better scaling
558
+ # This is less accurate but provides reasonable ordering
559
+ norm = last_token_hidden.norm(dim=-1).item()
560
+ score = min(1.0, max(0.0, norm / 50.0)) # Heuristic scaling
561
 
562
  return score
563
 
pipeline/calculations.py CHANGED
@@ -7,12 +7,15 @@ Implements deterministic calculations from FDAM v4.0.1:
7
  - Metals thresholds lookup
8
  """
9
 
 
10
  import math
11
  from dataclasses import dataclass, field
12
  from typing import Literal, Optional
13
 
14
  from ui.state import SessionState
15
 
 
 
16
 
17
  @dataclass
18
  class AirFiltrationResult:
@@ -279,6 +282,8 @@ class FDAMCalculator:
279
  Returns:
280
  Dictionary with all calculation results
281
  """
 
 
282
  # Calculate totals from rooms
283
  total_area = sum(r.length_ft * r.width_ft for r in session.rooms)
284
  total_volume = sum(
@@ -288,12 +293,14 @@ class FDAMCalculator:
288
  avg_ceiling = (
289
  total_volume / total_area if total_area > 0 else 10.0
290
  )
 
291
 
292
  # Air filtration
293
  air_filtration = self.calculate_air_filtration(
294
  total_area_sf=total_area,
295
  avg_ceiling_height_ft=avg_ceiling,
296
  )
 
297
 
298
  # Sample density
299
  sample_density = self.calculate_sample_density(
@@ -301,17 +308,25 @@ class FDAMCalculator:
301
  has_ceiling_deck=True, # Assume present
302
  surface_types_count=3, # Default assumption
303
  )
 
 
304
 
305
  # Regulatory flags
306
  regulatory = self.get_regulatory_flags(
307
  construction_era=session.project.construction_era or "post-2000",
308
  facility_classification=session.project.facility_classification or "non-operational",
309
  )
 
 
 
310
 
311
  # Metals thresholds
312
  thresholds = self.get_metals_thresholds(
313
  facility_classification=session.project.facility_classification or "non-operational",
314
  )
 
 
 
315
 
316
  return {
317
  "total_area_sf": total_area,
 
7
  - Metals thresholds lookup
8
  """
9
 
10
+ import logging
11
  import math
12
  from dataclasses import dataclass, field
13
  from typing import Literal, Optional
14
 
15
  from ui.state import SessionState
16
 
17
+ logger = logging.getLogger(__name__)
18
+
19
 
20
  @dataclass
21
  class AirFiltrationResult:
 
282
  Returns:
283
  Dictionary with all calculation results
284
  """
285
+ logger.debug(f"Running calculations for {len(session.rooms)} rooms")
286
+
287
  # Calculate totals from rooms
288
  total_area = sum(r.length_ft * r.width_ft for r in session.rooms)
289
  total_volume = sum(
 
293
  avg_ceiling = (
294
  total_volume / total_area if total_area > 0 else 10.0
295
  )
296
+ logger.debug(f"Totals: {total_area:.0f} SF, {total_volume:.0f} CF, avg ceiling {avg_ceiling:.1f} ft")
297
 
298
  # Air filtration
299
  air_filtration = self.calculate_air_filtration(
300
  total_area_sf=total_area,
301
  avg_ceiling_height_ft=avg_ceiling,
302
  )
303
+ logger.debug(f"Air filtration: {air_filtration.units_required} units required")
304
 
305
  # Sample density
306
  sample_density = self.calculate_sample_density(
 
308
  has_ceiling_deck=True, # Assume present
309
  surface_types_count=3, # Default assumption
310
  )
311
+ logger.debug(f"Sample density: tape={sample_density.tape_lifts_min}-{sample_density.tape_lifts_max}, "
312
+ f"wipes={sample_density.surface_wipes_min}-{sample_density.surface_wipes_max}")
313
 
314
  # Regulatory flags
315
  regulatory = self.get_regulatory_flags(
316
  construction_era=session.project.construction_era or "post-2000",
317
  facility_classification=session.project.facility_classification or "non-operational",
318
  )
319
+ if regulatory.notes:
320
+ for note in regulatory.notes:
321
+ logger.debug(f"Regulatory: {note}")
322
 
323
  # Metals thresholds
324
  thresholds = self.get_metals_thresholds(
325
  facility_classification=session.project.facility_classification or "non-operational",
326
  )
327
+ logger.debug(f"Metals thresholds ({thresholds.facility_type}): Pb={thresholds.lead_ug_100cm2} µg/100cm²")
328
+
329
+ logger.info(f"Calculations complete: {total_area:.0f} SF, {air_filtration.units_required} air units")
330
 
331
  return {
332
  "total_area_sf": total_area,
pipeline/dispositions.py CHANGED
@@ -295,6 +295,7 @@ class DispositionEngine:
295
  Returns:
296
  List of SurfaceDisposition for each analyzed surface
297
  """
 
298
  dispositions = []
299
 
300
  for image_id, result in vision_results.items():
@@ -360,5 +361,12 @@ class DispositionEngine:
360
  notes=disp_result.notes,
361
  )
362
  )
 
 
 
 
 
 
 
363
 
364
  return dispositions
 
295
  Returns:
296
  List of SurfaceDisposition for each analyzed surface
297
  """
298
+ logger.debug(f"Processing {len(vision_results)} vision results")
299
  dispositions = []
300
 
301
  for image_id, result in vision_results.items():
 
361
  notes=disp_result.notes,
362
  )
363
  )
364
+ logger.debug(f" {room_name}/{material_type}: {zone}/{condition} -> {disp_result.disposition}")
365
+
366
+ # Log disposition summary
367
+ disp_counts = {}
368
+ for d in dispositions:
369
+ disp_counts[d.disposition] = disp_counts.get(d.disposition, 0) + 1
370
+ logger.info(f"Dispositions generated: {dict(disp_counts)}")
371
 
372
  return dispositions
pipeline/generator.py CHANGED
@@ -4,11 +4,14 @@ Generates Cleaning Specification / Scope of Work documents
4
  with RAG-enhanced content from the FDAM knowledge base.
5
  """
6
 
 
7
  from dataclasses import dataclass
8
  from datetime import datetime
9
  from typing import Optional
10
 
11
  from ui.state import SessionState
 
 
12
  from rag import FDAMRetriever, ChromaVectorStore
13
  from .calculations import FDAMCalculator, AirFiltrationResult, SampleDensityResult, RegulatoryFlags
14
  from .dispositions import DispositionEngine, SurfaceDisposition
@@ -74,9 +77,11 @@ class DocumentGenerator:
74
  Returns:
75
  GeneratedDocument with markdown content
76
  """
 
77
  sections = []
78
 
79
  # Header
 
80
  header = self._generate_header(session)
81
  sections.append(header)
82
 
@@ -130,12 +135,15 @@ class DocumentGenerator:
130
 
131
  # Combine all sections
132
  markdown = "\n\n---\n\n".join(sections)
 
 
 
133
 
134
  return GeneratedDocument(
135
  markdown=markdown,
136
  title=f"SOW - {session.project.project_name}",
137
  generated_at=datetime.now().isoformat(),
138
- word_count=len(markdown.split()),
139
  sections=[
140
  "Header", "Project Info", "Scope Summary", "Room Inventory",
141
  "Vision Analysis", "Observations", "Dispositions",
 
4
  with RAG-enhanced content from the FDAM knowledge base.
5
  """
6
 
7
+ import logging
8
  from dataclasses import dataclass
9
  from datetime import datetime
10
  from typing import Optional
11
 
12
  from ui.state import SessionState
13
+
14
+ logger = logging.getLogger(__name__)
15
  from rag import FDAMRetriever, ChromaVectorStore
16
  from .calculations import FDAMCalculator, AirFiltrationResult, SampleDensityResult, RegulatoryFlags
17
  from .dispositions import DispositionEngine, SurfaceDisposition
 
77
  Returns:
78
  GeneratedDocument with markdown content
79
  """
80
+ logger.debug("Starting SOW document generation")
81
  sections = []
82
 
83
  # Header
84
+ logger.debug("Generating section: Header")
85
  header = self._generate_header(session)
86
  sections.append(header)
87
 
 
135
 
136
  # Combine all sections
137
  markdown = "\n\n---\n\n".join(sections)
138
+ word_count = len(markdown.split())
139
+
140
+ logger.info(f"Document generated: {word_count} words, {len(sections)} sections")
141
 
142
  return GeneratedDocument(
143
  markdown=markdown,
144
  title=f"SOW - {session.project.project_name}",
145
  generated_at=datetime.now().isoformat(),
146
+ word_count=word_count,
147
  sections=[
148
  "Header", "Project Info", "Scope Summary", "Room Inventory",
149
  "Vision Analysis", "Observations", "Dispositions",
pipeline/main.py CHANGED
@@ -10,6 +10,7 @@ Coordinates the 6-stage processing pipeline:
10
  """
11
 
12
  import logging
 
13
  from dataclasses import dataclass, field
14
  from datetime import datetime
15
  from typing import Callable, Optional
@@ -136,10 +137,18 @@ class FDAMPipeline:
136
  Returns:
137
  PipelineResult with all outputs
138
  """
 
139
  start_time = datetime.now()
140
  errors = []
141
  warnings = []
142
 
 
 
 
 
 
 
 
143
  def report_progress(stage: int, message: str = ""):
144
  if progress_callback:
145
  progress_callback(
@@ -153,6 +162,8 @@ class FDAMPipeline:
153
  )
154
 
155
  # Stage 1: Input Validation
 
 
156
  report_progress(1, "Validating inputs...")
157
  can_generate, validation_errors = session.can_generate()
158
 
@@ -165,6 +176,10 @@ class FDAMPipeline:
165
  if missing_ids:
166
  errors.append(f"{len(missing_ids)} image(s) need to be re-uploaded")
167
 
 
 
 
 
168
  return PipelineResult(
169
  success=False,
170
  session=session,
@@ -177,7 +192,11 @@ class FDAMPipeline:
177
  execution_time_seconds=(datetime.now() - start_time).total_seconds(),
178
  )
179
 
 
 
180
  # Stage 2: Vision Analysis
 
 
181
  report_progress(2, "Analyzing images with AI...")
182
  model_stack = get_models()
183
  vision_results = {}
@@ -185,6 +204,7 @@ class FDAMPipeline:
185
  room_mapping = {}
186
 
187
  for i, img_meta in enumerate(session.images):
 
188
  img_bytes = image_store.get(img_meta.id)
189
  if not img_bytes:
190
  warnings.append(f"Image {img_meta.filename} not found in store")
@@ -233,17 +253,29 @@ class FDAMPipeline:
233
  )
234
 
235
  except Exception as e:
 
236
  warnings.append(f"Error analyzing {img_meta.filename}: {e}")
237
 
 
 
 
238
  # Stage 3: RAG Retrieval
 
 
239
  report_progress(3, "Retrieving FDAM methodology context...")
240
  # RAG is integrated into disposition engine, just verify connection
241
  try:
242
- _ = self.retriever.retrieve("test connection", top_k=1)
 
243
  except Exception as e:
 
244
  warnings.append(f"RAG retrieval unavailable: {e}")
245
 
 
 
246
  # Stage 4: FDAM Logic (Dispositions)
 
 
247
  report_progress(4, "Applying disposition logic...")
248
 
249
  # Convert vision results to dict format for disposition engine
@@ -260,12 +292,21 @@ class FDAMPipeline:
260
  vision_results=vision_dict,
261
  room_mapping=room_mapping,
262
  )
 
 
263
 
264
  # Stage 5: Calculations
 
 
265
  report_progress(5, "Running FDAM calculations...")
266
  calculations = self.calculator.calculate_from_session(session)
 
 
 
267
 
268
  # Stage 6: Document Generation
 
 
269
  report_progress(6, "Generating documents...")
270
  document = self.generator.generate_sow(
271
  session=session,
@@ -273,6 +314,8 @@ class FDAMPipeline:
273
  surface_dispositions=dispositions,
274
  calculations=calculations,
275
  )
 
 
276
 
277
  # Update session
278
  session.has_results = True
@@ -280,6 +323,22 @@ class FDAMPipeline:
280
  session.update_timestamp()
281
 
282
  execution_time = (datetime.now() - start_time).total_seconds()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
  return PipelineResult(
285
  success=True,
 
10
  """
11
 
12
  import logging
13
+ import time
14
  from dataclasses import dataclass, field
15
  from datetime import datetime
16
  from typing import Callable, Optional
 
137
  Returns:
138
  PipelineResult with all outputs
139
  """
140
+ pipeline_start = time.time()
141
  start_time = datetime.now()
142
  errors = []
143
  warnings = []
144
 
145
+ logger.info("=" * 60)
146
+ logger.info("FDAM PIPELINE EXECUTION STARTED")
147
+ logger.info("=" * 60)
148
+ logger.info(f"Project: {session.project.project_name}")
149
+ logger.info(f"Facility: {session.project.facility_classification}")
150
+ logger.info(f"Rooms: {len(session.rooms)}, Images: {len(session.images)}")
151
+
152
  def report_progress(stage: int, message: str = ""):
153
  if progress_callback:
154
  progress_callback(
 
162
  )
163
 
164
  # Stage 1: Input Validation
165
+ stage_start = time.time()
166
+ logger.info("Stage 1/6: Input Validation")
167
  report_progress(1, "Validating inputs...")
168
  can_generate, validation_errors = session.can_generate()
169
 
 
176
  if missing_ids:
177
  errors.append(f"{len(missing_ids)} image(s) need to be re-uploaded")
178
 
179
+ logger.error(f"Validation failed with {len(errors)} error(s)")
180
+ for err in errors:
181
+ logger.error(f" - {err}")
182
+
183
  return PipelineResult(
184
  success=False,
185
  session=session,
 
192
  execution_time_seconds=(datetime.now() - start_time).total_seconds(),
193
  )
194
 
195
+ logger.debug(f"Stage 1 completed in {time.time() - stage_start:.2f}s")
196
+
197
  # Stage 2: Vision Analysis
198
+ stage_start = time.time()
199
+ logger.info(f"Stage 2/6: Vision Analysis ({len(session.images)} images)")
200
  report_progress(2, "Analyzing images with AI...")
201
  model_stack = get_models()
202
  vision_results = {}
 
204
  room_mapping = {}
205
 
206
  for i, img_meta in enumerate(session.images):
207
+ logger.debug(f"Analyzing image {i+1}/{len(session.images)}: {img_meta.filename}")
208
  img_bytes = image_store.get(img_meta.id)
209
  if not img_bytes:
210
  warnings.append(f"Image {img_meta.filename} not found in store")
 
253
  )
254
 
255
  except Exception as e:
256
+ logger.warning(f"Error analyzing {img_meta.filename}: {e}")
257
  warnings.append(f"Error analyzing {img_meta.filename}: {e}")
258
 
259
+ logger.info(f"Stage 2 completed in {time.time() - stage_start:.2f}s: "
260
+ f"{len(vision_results)} images analyzed")
261
+
262
  # Stage 3: RAG Retrieval
263
+ stage_start = time.time()
264
+ logger.info("Stage 3/6: RAG Retrieval")
265
  report_progress(3, "Retrieving FDAM methodology context...")
266
  # RAG is integrated into disposition engine, just verify connection
267
  try:
268
+ test_results = self.retriever.retrieve("test connection", top_k=1)
269
+ logger.debug(f"RAG connection verified: {len(test_results)} results")
270
  except Exception as e:
271
+ logger.warning(f"RAG retrieval unavailable: {e}")
272
  warnings.append(f"RAG retrieval unavailable: {e}")
273
 
274
+ logger.debug(f"Stage 3 completed in {time.time() - stage_start:.2f}s")
275
+
276
  # Stage 4: FDAM Logic (Dispositions)
277
+ stage_start = time.time()
278
+ logger.info("Stage 4/6: FDAM Logic (Dispositions)")
279
  report_progress(4, "Applying disposition logic...")
280
 
281
  # Convert vision results to dict format for disposition engine
 
292
  vision_results=vision_dict,
293
  room_mapping=room_mapping,
294
  )
295
+ logger.info(f"Stage 4 completed in {time.time() - stage_start:.2f}s: "
296
+ f"{len(dispositions)} dispositions generated")
297
 
298
  # Stage 5: Calculations
299
+ stage_start = time.time()
300
+ logger.info("Stage 5/6: Calculations")
301
  report_progress(5, "Running FDAM calculations...")
302
  calculations = self.calculator.calculate_from_session(session)
303
+ logger.debug(f"Calculations: area={calculations.get('total_area_sf', 0):.0f} SF, "
304
+ f"volume={calculations.get('total_volume_cf', 0):.0f} CF")
305
+ logger.debug(f"Stage 5 completed in {time.time() - stage_start:.2f}s")
306
 
307
  # Stage 6: Document Generation
308
+ stage_start = time.time()
309
+ logger.info("Stage 6/6: Document Generation")
310
  report_progress(6, "Generating documents...")
311
  document = self.generator.generate_sow(
312
  session=session,
 
314
  surface_dispositions=dispositions,
315
  calculations=calculations,
316
  )
317
+ logger.info(f"Stage 6 completed in {time.time() - stage_start:.2f}s: "
318
+ f"{len(document.sections)} sections generated")
319
 
320
  # Update session
321
  session.has_results = True
 
323
  session.update_timestamp()
324
 
325
  execution_time = (datetime.now() - start_time).total_seconds()
326
+ total_time = time.time() - pipeline_start
327
+
328
+ # Log final summary
329
+ logger.info("=" * 60)
330
+ logger.info("PIPELINE EXECUTION SUMMARY")
331
+ logger.info("=" * 60)
332
+ logger.info(f"Success: True")
333
+ logger.info(f"Total execution time: {total_time:.2f}s")
334
+ logger.info(f"Images analyzed: {len(vision_results)}")
335
+ logger.info(f"Dispositions generated: {len(dispositions)}")
336
+ logger.info(f"Document sections: {len(document.sections)}")
337
+ logger.info(f"Warnings: {len(warnings)}")
338
+ if warnings:
339
+ for w in warnings:
340
+ logger.warning(f" - {w}")
341
+ logger.info("=" * 60)
342
 
343
  return PipelineResult(
344
  success=True,
rag/retriever.py CHANGED
@@ -6,12 +6,16 @@ Implements tiered retrieval:
6
  3. Optional reranking for production
7
  """
8
 
 
 
9
  from typing import Optional
10
  from dataclasses import dataclass
11
 
12
  from config.settings import settings
13
  from .vectorstore import ChromaVectorStore
14
 
 
 
15
 
16
  @dataclass
17
  class RetrievalResult:
@@ -99,7 +103,7 @@ class RealReranker:
99
  from transformers import AutoModelForSequenceClassification, AutoTokenizer
100
 
101
  model_name = "Qwen/Qwen3-VL-Reranker-8B"
102
- print(f"Loading reranker model: {model_name}")
103
 
104
  self.tokenizer = AutoTokenizer.from_pretrained(
105
  model_name,
@@ -212,6 +216,9 @@ class FDAMRetriever:
212
  Returns:
213
  List of RetrievalResult objects, sorted by final_score descending
214
  """
 
 
 
215
  # Build metadata filter
216
  where_filter = None
217
  if category_filter or priority_filter:
@@ -232,6 +239,7 @@ class FDAMRetriever:
232
  )
233
 
234
  if not raw_results:
 
235
  return []
236
 
237
  # Convert to RetrievalResult objects with priority weighting
@@ -267,6 +275,7 @@ class FDAMRetriever:
267
 
268
  # Apply reranking if enabled
269
  if self.use_reranking and results:
 
270
  documents = [r.text for r in results]
271
  rerank_scores = self.reranker.rerank(query, documents)
272
 
@@ -278,7 +287,19 @@ class FDAMRetriever:
278
 
279
  # Sort by final score (descending) and take top_k
280
  results.sort(key=lambda x: x.final_score, reverse=True)
281
- return results[:top_k]
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
  def retrieve_for_context(
284
  self,
 
6
  3. Optional reranking for production
7
  """
8
 
9
+ import logging
10
+ import time
11
  from typing import Optional
12
  from dataclasses import dataclass
13
 
14
  from config.settings import settings
15
  from .vectorstore import ChromaVectorStore
16
 
17
+ logger = logging.getLogger(__name__)
18
+
19
 
20
  @dataclass
21
  class RetrievalResult:
 
103
  from transformers import AutoModelForSequenceClassification, AutoTokenizer
104
 
105
  model_name = "Qwen/Qwen3-VL-Reranker-8B"
106
+ logger.info(f"Loading reranker model: {model_name}")
107
 
108
  self.tokenizer = AutoTokenizer.from_pretrained(
109
  model_name,
 
216
  Returns:
217
  List of RetrievalResult objects, sorted by final_score descending
218
  """
219
+ start_time = time.time()
220
+ logger.debug(f"RAG retrieve: query='{query[:50]}...' top_k={top_k}")
221
+
222
  # Build metadata filter
223
  where_filter = None
224
  if category_filter or priority_filter:
 
239
  )
240
 
241
  if not raw_results:
242
+ logger.debug("RAG retrieve: no results found")
243
  return []
244
 
245
  # Convert to RetrievalResult objects with priority weighting
 
275
 
276
  # Apply reranking if enabled
277
  if self.use_reranking and results:
278
+ logger.debug(f"Applying reranking to {len(results)} results")
279
  documents = [r.text for r in results]
280
  rerank_scores = self.reranker.rerank(query, documents)
281
 
 
287
 
288
  # Sort by final score (descending) and take top_k
289
  results.sort(key=lambda x: x.final_score, reverse=True)
290
+ final_results = results[:top_k]
291
+
292
+ # Log retrieval summary
293
+ elapsed = time.time() - start_time
294
+ if final_results:
295
+ top_score = final_results[0].final_score
296
+ top_source = final_results[0].source
297
+ logger.debug(f"RAG retrieve: {len(final_results)} results in {elapsed:.3f}s, "
298
+ f"top_score={top_score:.3f}, top_source={top_source}")
299
+ else:
300
+ logger.debug(f"RAG retrieve: 0 results in {elapsed:.3f}s")
301
+
302
+ return final_results
303
 
304
  def retrieve_for_context(
305
  self,
rag/vectorstore.py CHANGED
@@ -5,6 +5,7 @@ Uses mock embeddings when MOCK_MODELS=true for local development.
5
  """
6
 
7
  import hashlib
 
8
  from typing import Optional
9
  from pathlib import Path
10
 
@@ -14,15 +15,17 @@ from chromadb.config import Settings
14
  from config.settings import settings
15
  from .chunker import Chunk
16
 
 
 
17
 
18
  class MockEmbeddingFunction:
19
  """Mock embedding function for local development.
20
 
21
  Generates deterministic pseudo-embeddings based on text hash.
22
- Produces 384-dimensional vectors (matches common embedding models).
23
  """
24
 
25
- EMBEDDING_DIM = 384
26
 
27
  def __call__(self, input: list[str]) -> list[list[float]]:
28
  """Generate mock embeddings for a list of texts."""
@@ -32,8 +35,10 @@ class MockEmbeddingFunction:
32
  """Generate a deterministic pseudo-embedding from text.
33
 
34
  Uses SHA-256 hash expanded to fill embedding dimensions.
35
- Not semantically meaningful but provides consistent behavior.
36
  """
 
 
37
  # Hash the text
38
  text_hash = hashlib.sha256(text.encode("utf-8")).digest()
39
 
@@ -45,16 +50,24 @@ class MockEmbeddingFunction:
45
  normalized = (byte_val / 127.5) - 1.0
46
  embedding.append(normalized)
47
 
 
 
 
 
 
48
  return embedding
49
 
50
 
51
  class RealEmbeddingFunction:
52
  """Real embedding function using Qwen3-VL-Embedding-8B.
53
 
 
54
  Loaded on-demand when MOCK_MODELS=false.
 
 
55
  """
56
 
57
- EMBEDDING_DIM = 4096 # Qwen embedding dimension
58
 
59
  def __init__(self):
60
  self.model = None
@@ -69,7 +82,7 @@ class RealEmbeddingFunction:
69
  from transformers import AutoModel, AutoTokenizer
70
 
71
  model_name = "Qwen/Qwen3-VL-Embedding-8B"
72
- print(f"Loading embedding model: {model_name}")
73
 
74
  self.tokenizer = AutoTokenizer.from_pretrained(
75
  model_name,
@@ -83,8 +96,23 @@ class RealEmbeddingFunction:
83
  )
84
  self.model.eval()
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def __call__(self, input: list[str]) -> list[list[float]]:
87
- """Generate embeddings for a list of texts."""
88
  self._load_model()
89
 
90
  import torch
@@ -102,9 +130,18 @@ class RealEmbeddingFunction:
102
  inputs = {k: v.to(self.model.device) for k, v in inputs.items()}
103
 
104
  outputs = self.model(**inputs)
105
- # Use mean pooling over sequence
106
- embedding = outputs.last_hidden_state.mean(dim=1).squeeze()
107
- embeddings.append(embedding.cpu().float().tolist())
 
 
 
 
 
 
 
 
 
108
 
109
  return embeddings
110
 
@@ -140,23 +177,28 @@ class ChromaVectorStore:
140
  if persist_directory:
141
  persist_path = Path(persist_directory)
142
  persist_path.mkdir(parents=True, exist_ok=True)
 
143
  self.client = chromadb.PersistentClient(
144
  path=str(persist_path),
145
  settings=Settings(anonymized_telemetry=False),
146
  )
147
  else:
 
148
  self.client = chromadb.Client(
149
  settings=Settings(anonymized_telemetry=False),
150
  )
151
 
152
  # Set up embedding function
153
  self.embedding_function = embedding_function or get_embedding_function()
 
 
154
 
155
  # Get or create collection
156
  self.collection = self.client.get_or_create_collection(
157
  name=self.COLLECTION_NAME,
158
  metadata={"hnsw:space": "cosine"},
159
  )
 
160
 
161
  def add_chunks(self, chunks: list[Chunk]) -> int:
162
  """Add chunks to the vector store.
 
5
  """
6
 
7
  import hashlib
8
+ import logging
9
  from typing import Optional
10
  from pathlib import Path
11
 
 
15
  from config.settings import settings
16
  from .chunker import Chunk
17
 
18
+ logger = logging.getLogger(__name__)
19
+
20
 
21
  class MockEmbeddingFunction:
22
  """Mock embedding function for local development.
23
 
24
  Generates deterministic pseudo-embeddings based on text hash.
25
+ Produces 4096-dimensional vectors (matches Qwen3-VL-Embedding-8B).
26
  """
27
 
28
+ EMBEDDING_DIM = 4096 # Per Qwen3-VL-Embedding-8B hidden_size
29
 
30
  def __call__(self, input: list[str]) -> list[list[float]]:
31
  """Generate mock embeddings for a list of texts."""
 
35
  """Generate a deterministic pseudo-embedding from text.
36
 
37
  Uses SHA-256 hash expanded to fill embedding dimensions.
38
+ L2 normalized to match real model output.
39
  """
40
+ import math
41
+
42
  # Hash the text
43
  text_hash = hashlib.sha256(text.encode("utf-8")).digest()
44
 
 
50
  normalized = (byte_val / 127.5) - 1.0
51
  embedding.append(normalized)
52
 
53
+ # L2 normalize (matching real model behavior)
54
+ norm = math.sqrt(sum(x * x for x in embedding))
55
+ if norm > 0:
56
+ embedding = [x / norm for x in embedding]
57
+
58
  return embedding
59
 
60
 
61
  class RealEmbeddingFunction:
62
  """Real embedding function using Qwen3-VL-Embedding-8B.
63
 
64
+ Uses last-token pooling per official Qwen3-VL-Embedding implementation.
65
  Loaded on-demand when MOCK_MODELS=false.
66
+
67
+ Reference: https://github.com/QwenLM/Qwen3-VL-Embedding
68
  """
69
 
70
+ EMBEDDING_DIM = 4096 # Per Qwen3-VL-Embedding-8B hidden_size
71
 
72
  def __init__(self):
73
  self.model = None
 
82
  from transformers import AutoModel, AutoTokenizer
83
 
84
  model_name = "Qwen/Qwen3-VL-Embedding-8B"
85
+ logger.info(f"Loading embedding model: {model_name}")
86
 
87
  self.tokenizer = AutoTokenizer.from_pretrained(
88
  model_name,
 
96
  )
97
  self.model.eval()
98
 
99
+ @staticmethod
100
+ def _pooling_last(hidden_state, attention_mask):
101
+ """Extract the last valid token's hidden state.
102
+
103
+ Official pooling method from Qwen3-VL-Embedding.
104
+ Finds the last position where attention_mask == 1 and extracts that token.
105
+ """
106
+ import torch
107
+
108
+ flipped_tensor = attention_mask.flip(dims=[1])
109
+ last_one_positions = flipped_tensor.argmax(dim=1)
110
+ col = attention_mask.shape[1] - last_one_positions - 1
111
+ row = torch.arange(hidden_state.shape[0], device=hidden_state.device)
112
+ return hidden_state[row, col]
113
+
114
  def __call__(self, input: list[str]) -> list[list[float]]:
115
+ """Generate embeddings for a list of texts using last-token pooling."""
116
  self._load_model()
117
 
118
  import torch
 
130
  inputs = {k: v.to(self.model.device) for k, v in inputs.items()}
131
 
132
  outputs = self.model(**inputs)
133
+
134
+ # Use last-token pooling (official Qwen3-VL-Embedding method)
135
+ attention_mask = inputs.get("attention_mask")
136
+ if attention_mask is not None:
137
+ embedding = self._pooling_last(outputs.last_hidden_state, attention_mask)
138
+ else:
139
+ # Fallback: use last token if no attention mask
140
+ embedding = outputs.last_hidden_state[:, -1, :]
141
+
142
+ # L2 normalize (per official implementation)
143
+ embedding = torch.nn.functional.normalize(embedding, p=2, dim=-1)
144
+ embeddings.append(embedding.squeeze().cpu().float().tolist())
145
 
146
  return embeddings
147
 
 
177
  if persist_directory:
178
  persist_path = Path(persist_directory)
179
  persist_path.mkdir(parents=True, exist_ok=True)
180
+ logger.debug(f"ChromaDB: using persistent storage at {persist_path}")
181
  self.client = chromadb.PersistentClient(
182
  path=str(persist_path),
183
  settings=Settings(anonymized_telemetry=False),
184
  )
185
  else:
186
+ logger.debug("ChromaDB: using in-memory storage")
187
  self.client = chromadb.Client(
188
  settings=Settings(anonymized_telemetry=False),
189
  )
190
 
191
  # Set up embedding function
192
  self.embedding_function = embedding_function or get_embedding_function()
193
+ embed_type = "mock" if settings.mock_models else "real"
194
+ logger.debug(f"ChromaDB: using {embed_type} embeddings")
195
 
196
  # Get or create collection
197
  self.collection = self.client.get_or_create_collection(
198
  name=self.COLLECTION_NAME,
199
  metadata={"hnsw:space": "cosine"},
200
  )
201
+ logger.info(f"ChromaDB collection '{self.COLLECTION_NAME}' ready: {self.collection.count()} chunks")
202
 
203
  def add_chunks(self, chunks: list[Chunk]) -> int:
204
  """Add chunks to the vector store.
requirements-dev.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Development dependencies for FDAM AI Pipeline
2
+
3
+ # Testing
4
+ pytest
5
+ pytest-asyncio
6
+ pytest-playwright
7
+ playwright
8
+
9
+ # Code quality
10
+ ruff
11
+ mypy
requirements.txt CHANGED
@@ -6,7 +6,7 @@ qwen-vl-utils>=0.0.14
6
  torchvision
7
 
8
  # UI
9
- gradio
10
 
11
  # RAG/Vector Store
12
  chromadb
 
6
  torchvision
7
 
8
  # UI
9
+ gradio>=6.0.0,<7.0.0
10
 
11
  # RAG/Vector Store
12
  chromadb
sample_images/Bar and dining area1.jpg ADDED

Git LFS Details

  • SHA256: 68615a29112c8ba8633358730ac8348a6fe55efff115b92b29cad83d2dc2cbbd
  • Pointer size: 131 Bytes
  • Size of remote file: 159 kB
sample_images/Bar and dining area2.jpg ADDED

Git LFS Details

  • SHA256: 28747a6c8f68335dc16adbc3b3cf86003f4ac757de31f3b4075f5eec6aa393fc
  • Pointer size: 131 Bytes
  • Size of remote file: 172 kB
sample_images/Bar and dining area3.jpg ADDED

Git LFS Details

  • SHA256: 6e2f9de89fb348d2fdad93af7aba7e58810993be254d66e2b04edddff377dd1e
  • Pointer size: 131 Bytes
  • Size of remote file: 165 kB
sample_images/Bar area1.jpg ADDED

Git LFS Details

  • SHA256: 6b49d8fed49381439fb0e3fe89f8286201acfcf68789a9127a9355cdf5d34fba
  • Pointer size: 131 Bytes
  • Size of remote file: 103 kB
sample_images/Bar area2.jpg ADDED

Git LFS Details

  • SHA256: 32b7cf9451e204ada29b868b4ece45af41d8183c07ccc07b6174bf51f08872b6
  • Pointer size: 130 Bytes
  • Size of remote file: 98.1 kB
sample_images/Bar area3.jpg ADDED

Git LFS Details

  • SHA256: e4ab8d5a5d08cbcc6d2011f9a530866ef773d8fbc70419d09b69e44d3c3bb3c4
  • Pointer size: 131 Bytes
  • Size of remote file: 143 kB
sample_images/Kitchen 1.jpg ADDED

Git LFS Details

  • SHA256: de380d03d01b2346f23c17df511eeff82bb185217a75743ab68857a3e8c27e5a
  • Pointer size: 131 Bytes
  • Size of remote file: 109 kB
sample_images/Kitchen 2.jpg ADDED

Git LFS Details

  • SHA256: ba793c74b89d1491c9a79084f78fbb8b99fe7688b65a1346f6f6c678ac89cd2e
  • Pointer size: 131 Bytes
  • Size of remote file: 100 kB
sample_images/Kitchen 3.jpg ADDED

Git LFS Details

  • SHA256: 7ccc0d89f75580f55ecadecdcd70e51684e58a536d75c466ec37085a16f44681
  • Pointer size: 130 Bytes
  • Size of remote file: 74.1 kB
sample_images/Kitchen 4.jpg ADDED

Git LFS Details

  • SHA256: c26f0d9b4d821b0eb91b226ff5f27fae3f21e91a70698b8683d45bb4c5e9fb8e
  • Pointer size: 130 Bytes
  • Size of remote file: 91.4 kB
sample_images/Kitchen 5.jpg ADDED

Git LFS Details

  • SHA256: d6297dce0dd95843867c54a96982c43d114c4ae76cf082e3b9f02c34c2b54730
  • Pointer size: 130 Bytes
  • Size of remote file: 90.8 kB
sample_images/Kitchen 6.jpg ADDED

Git LFS Details

  • SHA256: f0d5ff5c21d5cf8701f954760875e96017adedc658b21b23be9a463893ce3a59
  • Pointer size: 131 Bytes
  • Size of remote file: 105 kB
sample_images/factory_area.jpg ADDED

Git LFS Details

  • SHA256: fc80d0d933e37b1de3442ae901296f6a0f7c1f13850f9373a98dd3f36ece973b
  • Pointer size: 130 Bytes
  • Size of remote file: 53.9 kB
sample_images/factory_area.jpg:Zone.Identifier ADDED
Binary file (25 Bytes). View file
 
tests/conftest.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pytest fixtures for Playwright E2E tests."""
2
+
3
+ import pytest
4
+ import subprocess
5
+ import time
6
+ import os
7
+ import urllib.request
8
+ import urllib.error
9
+ from playwright.sync_api import sync_playwright, Browser, Page
10
+
11
+ GRADIO_PORT = 7860
12
+ GRADIO_URL = f"http://localhost:{GRADIO_PORT}"
13
+
14
+
15
+ @pytest.fixture(scope="session")
16
+ def gradio_server():
17
+ """Start Gradio server for E2E tests."""
18
+ env = os.environ.copy()
19
+ env["MOCK_MODELS"] = "true"
20
+
21
+ # Use venv python for consistent environment
22
+ python_cmd = ".venv/bin/python" if os.path.exists(".venv/bin/python") else "python3"
23
+
24
+ # Don't capture output - let it go to console for debugging
25
+ process = subprocess.Popen(
26
+ [python_cmd, "app.py"],
27
+ env=env,
28
+ )
29
+
30
+ # Wait for server to start with health check
31
+ max_retries = 30
32
+ for i in range(max_retries):
33
+ try:
34
+ urllib.request.urlopen(GRADIO_URL, timeout=1)
35
+ break
36
+ except (urllib.error.URLError, ConnectionRefusedError):
37
+ time.sleep(1)
38
+ else:
39
+ process.terminate()
40
+ raise RuntimeError("Gradio server failed to start")
41
+
42
+ yield GRADIO_URL
43
+
44
+ process.terminate()
45
+ process.wait()
46
+
47
+
48
+ @pytest.fixture(scope="session")
49
+ def browser_instance():
50
+ """Create browser instance for the session."""
51
+ # Use headless mode for WSL/CI environments, headed for local debugging
52
+ headless = os.environ.get("PLAYWRIGHT_HEADLESS", "true").lower() == "true"
53
+
54
+ with sync_playwright() as p:
55
+ browser = p.chromium.launch(headless=headless)
56
+ yield browser
57
+ browser.close()
58
+
59
+
60
+ @pytest.fixture
61
+ def page(browser_instance, gradio_server):
62
+ """Create new page for each test."""
63
+ context = browser_instance.new_context()
64
+ page = context.new_page()
65
+ page.goto(gradio_server)
66
+ page.wait_for_selector("#sample_dropdown", timeout=10000)
67
+ yield page
68
+ context.close()
tests/test_e2e_forms.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """E2E tests for form interactions."""
2
+
3
+ import pytest
4
+ from playwright.sync_api import Page, expect
5
+
6
+
7
+ def select_sample(page: Page, sample_text: str):
8
+ """Helper to select a sample from the dropdown."""
9
+ page.locator("#sample_dropdown input[role='listbox']").click()
10
+ page.wait_for_timeout(300)
11
+ page.locator(f"[role='option']:has-text('{sample_text}')").click()
12
+ # Wait for form to be populated - check that project_name has a value
13
+ # (status gets cleared when dropdown resets, so we can't rely on it)
14
+ page.wait_for_function(
15
+ """() => {
16
+ const textarea = document.querySelector('#project_name textarea');
17
+ return textarea && textarea.value && textarea.value.includes('Sample:');
18
+ }""",
19
+ timeout=30000
20
+ )
21
+
22
+
23
+ class TestProjectForm:
24
+ """Test Tab 1 project form."""
25
+
26
+ def test_zip_validation_invalid(self, page: Page):
27
+ """Test ZIP code format validation with invalid input."""
28
+ # zip_code uses input (max_lines=1), not textarea
29
+ zip_input = page.locator("#zip_code input")
30
+
31
+ # Invalid ZIP (too short)
32
+ zip_input.fill("123")
33
+ zip_input.blur()
34
+ page.wait_for_timeout(300)
35
+
36
+ # Should show invalid message (✗ indicates error)
37
+ expect(page.locator("#zip_validation")).to_contain_text("✗")
38
+
39
+ def test_zip_validation_valid(self, page: Page):
40
+ """Test ZIP code format validation with valid input."""
41
+ # zip_code uses input (max_lines=1), not textarea
42
+ zip_input = page.locator("#zip_code input")
43
+
44
+ # Valid 5-digit ZIP
45
+ zip_input.fill("12345")
46
+ zip_input.blur()
47
+ page.wait_for_timeout(300)
48
+
49
+ # Should show valid checkmark
50
+ expect(page.locator("#zip_validation")).to_contain_text("Valid")
51
+
52
+ def test_zip_validation_valid_plus4(self, page: Page):
53
+ """Test ZIP+4 format validation."""
54
+ # zip_code uses input (max_lines=1), not textarea
55
+ zip_input = page.locator("#zip_code input")
56
+
57
+ # Valid ZIP+4
58
+ zip_input.fill("12345-6789")
59
+ zip_input.blur()
60
+ page.wait_for_timeout(300)
61
+
62
+ expect(page.locator("#zip_validation")).to_contain_text("Valid")
63
+
64
+ def test_facility_classification_radio(self, page: Page):
65
+ """Test facility classification radio buttons."""
66
+ # Use specific selector by value attribute to avoid substring matching
67
+ facility_group = page.locator("#facility_classification")
68
+
69
+ # Click Operational radio (use value attribute for exact match)
70
+ facility_group.locator("input[value='Operational']").click()
71
+ page.wait_for_timeout(200)
72
+
73
+ # Verify it's selected
74
+ expect(facility_group.locator("input[value='Operational']")).to_be_checked()
75
+
76
+ # Click Non-Operational
77
+ facility_group.locator("input[value='Non-Operational']").click()
78
+ page.wait_for_timeout(200)
79
+
80
+ expect(facility_group.locator("input[value='Non-Operational']")).to_be_checked()
81
+ expect(facility_group.locator("input[value='Operational']")).not_to_be_checked()
82
+
83
+ def test_construction_era_radio(self, page: Page):
84
+ """Test construction era radio buttons."""
85
+ page.get_by_label("Pre-1980").click()
86
+ expect(page.get_by_label("Pre-1980")).to_be_checked()
87
+
88
+ page.get_by_label("Post-2000").click()
89
+ expect(page.get_by_label("Post-2000")).to_be_checked()
90
+
91
+
92
+ class TestRoomsForm:
93
+ """Test Tab 2 rooms form."""
94
+
95
+ def test_room_exists_after_sample_load(self, page: Page):
96
+ """Test room is created when sample is loaded."""
97
+ select_sample(page, "Bar & Dining Area")
98
+
99
+ # Go to Rooms tab
100
+ page.locator("#tab-rooms-button").click()
101
+ page.wait_for_timeout(500)
102
+
103
+ # Room should exist in table
104
+ expect(page.locator("#rooms_table")).to_contain_text("Bar & Dining Area")
105
+
106
+ def test_custom_height_visibility_toggle(self, page: Page):
107
+ """Test custom height field appears when 'Custom' selected."""
108
+ page.locator("#tab-rooms-button").click()
109
+ page.wait_for_timeout(300)
110
+
111
+ # Select a standard height first - click dropdown input to open
112
+ dropdown_input = page.locator("#room_height_preset input[role='listbox']")
113
+ dropdown_input.click()
114
+ page.wait_for_timeout(300)
115
+ page.locator("[role='option']:has-text('10 ft')").click()
116
+ page.wait_for_timeout(300)
117
+
118
+ # Custom height should be hidden
119
+ expect(page.locator("#room_height_custom")).not_to_be_visible()
120
+
121
+ # Select Custom - click dropdown input to open
122
+ dropdown_input.click()
123
+ page.wait_for_timeout(300)
124
+ page.locator("[role='option']:has-text('Custom')").click()
125
+ page.wait_for_timeout(300)
126
+
127
+ # Custom height should appear
128
+ expect(page.locator("#room_height_custom")).to_be_visible()
129
+
130
+ def test_room_validation_requires_name(self, page: Page):
131
+ """Test that room name is required."""
132
+ page.locator("#tab-rooms-button").click()
133
+ page.wait_for_timeout(300)
134
+
135
+ # Try to add room with empty name
136
+ page.locator("#room_length input").fill("20")
137
+ page.locator("#room_width input").fill("15")
138
+
139
+ # Select height from dropdown - click the input to open
140
+ page.locator("#room_height_preset input[role='listbox']").click()
141
+ page.wait_for_timeout(300)
142
+ page.locator("[role='option']:has-text('10 ft')").click()
143
+ page.wait_for_timeout(300)
144
+
145
+ page.get_by_role("button", name="Add Room").click()
146
+ page.wait_for_timeout(300)
147
+
148
+ # Should show validation error about room name
149
+ expect(page.locator("#tab2_validation")).to_contain_text("Room name")
150
+
151
+
152
+ class TestImagesForm:
153
+ """Test Tab 3 images form."""
154
+
155
+ def test_images_gallery_shows_sample_images(self, page: Page):
156
+ """Test gallery displays images after sample load."""
157
+ select_sample(page, "Bar & Dining Area")
158
+
159
+ # Go to Images tab
160
+ page.locator("#tab-images-button").click()
161
+ page.wait_for_timeout(500)
162
+
163
+ # Gallery should have images
164
+ gallery = page.locator("#images_gallery")
165
+ expect(gallery).to_be_visible()
166
+
167
+ # Should have 3 images
168
+ images = gallery.locator("img")
169
+ expect(images).to_have_count(3)
170
+
171
+ def test_room_dropdown_populated(self, page: Page):
172
+ """Test room dropdown is populated after sample load."""
173
+ select_sample(page, "Bar & Dining Area")
174
+
175
+ # Go to Images tab
176
+ page.locator("#tab-images-button").click()
177
+ page.wait_for_timeout(500)
178
+
179
+ # Click dropdown input to open it and verify room is in options
180
+ page.locator("#room_select input[role='listbox']").click()
181
+ page.wait_for_timeout(300)
182
+
183
+ # Room should appear in dropdown options
184
+ expect(page.locator("[role='option']:has-text('Bar & Dining Area')")).to_be_visible()
185
+
186
+
187
+ class TestObservationsForm:
188
+ """Test Tab 4 observations form."""
189
+
190
+ def test_checkbox_interactions(self, page: Page):
191
+ """Test observation checkboxes can be toggled."""
192
+ page.locator("#tab-observations-button").click()
193
+ page.wait_for_timeout(300)
194
+
195
+ # Check smoke odor
196
+ smoke_checkbox = page.locator("#smoke_odor input[type='checkbox']")
197
+ smoke_checkbox.check()
198
+ expect(smoke_checkbox).to_be_checked()
199
+
200
+ # Uncheck
201
+ smoke_checkbox.uncheck()
202
+ expect(smoke_checkbox).not_to_be_checked()
203
+
204
+ def test_odor_intensity_radio(self, page: Page):
205
+ """Test odor intensity radio buttons."""
206
+ page.locator("#tab-observations-button").click()
207
+ page.wait_for_timeout(300)
208
+
209
+ # Use specific selector within odor_intensity group to avoid matching char_density
210
+ odor_group = page.locator("#odor_intensity")
211
+
212
+ odor_group.get_by_label("Strong").click()
213
+ expect(odor_group.get_by_label("Strong")).to_be_checked()
214
+
215
+ odor_group.get_by_label("Moderate").click()
216
+ expect(odor_group.get_by_label("Moderate")).to_be_checked()
217
+ expect(odor_group.get_by_label("Strong")).not_to_be_checked()
218
+
219
+ def test_observations_persist_after_sample_load(self, page: Page):
220
+ """Test observations are populated from sample."""
221
+ select_sample(page, "Factory Area")
222
+
223
+ # Go to Observations tab
224
+ page.locator("#tab-observations-button").click()
225
+ page.wait_for_timeout(500)
226
+
227
+ # Factory sample has smoke odor = True
228
+ smoke_checkbox = page.locator("#smoke_odor input[type='checkbox']")
229
+ expect(smoke_checkbox).to_be_checked()
230
+
231
+
232
+ class TestDebugSelectors:
233
+ """Debug tests to verify Gradio HTML structure."""
234
+
235
+ def test_capture_dropdown_and_sample_load(self, page: Page):
236
+ """Capture dropdown HTML and test full sample load.
237
+
238
+ Run with: pytest tests/test_e2e_forms.py::TestDebugSelectors::test_capture_dropdown_and_sample_load -v -s
239
+ """
240
+ page.wait_for_timeout(2000)
241
+
242
+ # Click dropdown to open
243
+ print("\n--- Opening dropdown ---")
244
+ dropdown_input = page.locator("#sample_dropdown input[role='listbox']")
245
+ dropdown_input.click()
246
+ page.wait_for_timeout(500)
247
+
248
+ # Click the Bar & Dining option
249
+ print("Clicking Bar & Dining option...")
250
+ page.locator("[role='option']:has-text('Bar & Dining Area')").click()
251
+ page.wait_for_timeout(2000) # Wait for sample to load
252
+
253
+ # Check sample_status HTML
254
+ print("\n--- DEBUG: Status HTML ---")
255
+ try:
256
+ status_html = page.locator("#sample_status").evaluate("el => el.outerHTML")
257
+ print("Sample status HTML:", status_html[:500])
258
+ except Exception as e:
259
+ print(f"Error getting status: {e}")
260
+
261
+ # Check if project name was populated
262
+ print("\n--- DEBUG: Project Name ---")
263
+ try:
264
+ # Get the full HTML of project_name element
265
+ pn_html = page.locator("#project_name").evaluate("el => el.outerHTML")
266
+ print(f"Project name HTML:\n{pn_html[:800]}")
267
+
268
+ # Try different selectors
269
+ print("\nTrying different selectors:")
270
+ print(f" #project_name input count: {page.locator('#project_name input').count()}")
271
+ print(f" #project_name textarea count: {page.locator('#project_name textarea').count()}")
272
+ print(f" #project_name [data-testid] count: {page.locator('#project_name [data-testid]').count()}")
273
+
274
+ # Check if there's any input/textarea in the document
275
+ all_inputs = page.locator("input[type='text']").count()
276
+ print(f" Total text inputs on page: {all_inputs}")
277
+
278
+ except Exception as e:
279
+ print(f"Error: {e}")
280
+
281
+ print("--- END DEBUG ---\n")
282
+
283
+ def test_capture_full_page_structure(self, page: Page):
284
+ """Capture key element structures.
285
+
286
+ Run with: pytest tests/test_e2e_forms.py::TestDebugSelectors::test_capture_full_page_structure -v -s
287
+ """
288
+ page.wait_for_timeout(2000)
289
+
290
+ print("\n--- DEBUG: Page Structure ---")
291
+
292
+ # Capture project name structure
293
+ project_name = page.locator("#project_name")
294
+ print("Project name HTML:", project_name.evaluate("el => el.outerHTML")[:500])
295
+
296
+ # Capture tab button structure
297
+ try:
298
+ tab_btn = page.locator("#tab-project-button")
299
+ print("Tab button exists:", tab_btn.count() > 0)
300
+ except Exception as e:
301
+ print(f"Tab button error: {e}")
302
+
303
+ # Try to find tab by different selectors
304
+ tabs = page.locator('[role="tab"]').all()
305
+ print(f"Found {len(tabs)} tabs with role=tab")
306
+
307
+ print("--- END DEBUG ---\n")
tests/test_e2e_samples.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """E2E tests for sample scenario loading."""
2
+
3
+ import pytest
4
+ from playwright.sync_api import Page, expect
5
+
6
+
7
+ def select_sample(page: Page, sample_text: str):
8
+ """Helper to select a sample from the dropdown.
9
+
10
+ Args:
11
+ page: Playwright page
12
+ sample_text: Text of the sample option to select (e.g., "Bar & Dining Area")
13
+ """
14
+ # Gradio dropdowns use input[role='listbox'] - click to open
15
+ page.locator("#sample_dropdown input[role='listbox']").click()
16
+ page.wait_for_timeout(300) # Wait for dropdown to open
17
+
18
+ # Click the option with matching text
19
+ page.locator(f"[role='option']:has-text('{sample_text}')").click()
20
+
21
+ # Wait for form to be populated - check that project_name has a value
22
+ # Gradio 6.x uses textarea for textboxes
23
+ page.wait_for_function(
24
+ """() => {
25
+ const textarea = document.querySelector('#project_name textarea');
26
+ return textarea && textarea.value && textarea.value.includes('Sample:');
27
+ }""",
28
+ timeout=30000
29
+ )
30
+
31
+
32
+ class TestSampleLoading:
33
+ """Test loading sample scenarios via dropdown."""
34
+
35
+ def test_bar_dining_loads_correctly(self, page: Page):
36
+ """Verify Bar & Dining sample loads all data."""
37
+ select_sample(page, "Bar & Dining Area")
38
+
39
+ # Verify Tab 1 populated
40
+ expect(page.locator("#project_name textarea")).to_have_value(
41
+ "Sample: Bar & Dining Fire Assessment"
42
+ )
43
+ expect(page.locator("#city textarea")).to_have_value("Springfield")
44
+
45
+ def test_bar_area_loads_correctly(self, page: Page):
46
+ """Verify Bar Area sample loads all data."""
47
+ select_sample(page, "Bar Area")
48
+
49
+ expect(page.locator("#project_name textarea")).to_have_value(
50
+ "Sample: Bar Area Fire Assessment"
51
+ )
52
+
53
+ def test_kitchen_loads_6_images(self, page: Page):
54
+ """Verify Kitchen sample loads 6 images."""
55
+ select_sample(page, "Kitchen")
56
+
57
+ # Navigate to Images tab
58
+ page.locator("#tab-images-button").click()
59
+
60
+ # Wait for gallery to populate - may take time for images to render
61
+ page.wait_for_function(
62
+ """() => {
63
+ const gallery = document.querySelector('#images_gallery');
64
+ const images = gallery ? gallery.querySelectorAll('img') : [];
65
+ return images.length > 0;
66
+ }""",
67
+ timeout=10000
68
+ )
69
+
70
+ # Verify image count in gallery
71
+ gallery = page.locator("#images_gallery")
72
+ images = gallery.locator("img")
73
+ expect(images).to_have_count(6)
74
+
75
+ def test_factory_loads_1_image(self, page: Page):
76
+ """Verify Factory sample loads 1 image."""
77
+ select_sample(page, "Factory Area")
78
+
79
+ # Navigate to Images tab
80
+ page.locator("#tab-images-button").click()
81
+ page.wait_for_timeout(500)
82
+
83
+ # Verify image count
84
+ gallery = page.locator("#images_gallery")
85
+ images = gallery.locator("img")
86
+ expect(images).to_have_count(1)
87
+
88
+ def test_factory_operational_facility(self, page: Page):
89
+ """Verify Factory sample has operational classification."""
90
+ select_sample(page, "Factory Area")
91
+
92
+ # Check facility classification shows Operational
93
+ expect(page.locator("#facility_classification")).to_contain_text("Operational")
94
+
95
+ def test_sample_dropdown_resets_after_selection(self, page: Page):
96
+ """Verify dropdown resets to placeholder after loading sample."""
97
+ select_sample(page, "Bar & Dining Area")
98
+
99
+ # Dropdown should reset to placeholder text
100
+ dropdown_input = page.locator("#sample_dropdown input[role='listbox']")
101
+ expect(dropdown_input).to_have_value("Select a sample scenario...")
102
+
103
+
104
+ class TestSampleRoomData:
105
+ """Test that sample rooms load correct data."""
106
+
107
+ def test_bar_dining_room_dimensions(self, page: Page):
108
+ """Verify Bar & Dining room has correct dimensions."""
109
+ select_sample(page, "Bar & Dining Area")
110
+
111
+ # Navigate to Rooms tab
112
+ page.locator("#tab-rooms-button").click()
113
+ page.wait_for_timeout(500)
114
+
115
+ # Verify room table shows the room
116
+ expect(page.locator("#rooms_table")).to_contain_text("Bar & Dining Area")
117
+ expect(page.locator("#rooms_table")).to_contain_text("40") # Length
118
+ expect(page.locator("#rooms_table")).to_contain_text("30") # Width
119
+
120
+ def test_kitchen_room_dimensions(self, page: Page):
121
+ """Verify Kitchen room has correct dimensions."""
122
+ select_sample(page, "Kitchen")
123
+
124
+ # Navigate to Rooms tab
125
+ page.locator("#tab-rooms-button").click()
126
+ page.wait_for_timeout(500)
127
+
128
+ # Verify room name
129
+ expect(page.locator("#rooms_table")).to_contain_text("Commercial Kitchen")
130
+
131
+
132
+ class TestSampleObservations:
133
+ """Test that sample observations load correctly."""
134
+
135
+ def test_bar_dining_observations(self, page: Page):
136
+ """Verify Bar & Dining sample loads observations."""
137
+ select_sample(page, "Bar & Dining Area")
138
+
139
+ # Navigate to Observations tab
140
+ page.locator("#tab-observations-button").click()
141
+ page.wait_for_timeout(500)
142
+
143
+ # Verify smoke odor checkbox is checked
144
+ smoke_checkbox = page.locator("#smoke_odor input[type='checkbox']")
145
+ expect(smoke_checkbox).to_be_checked()
146
+
147
+ # Verify odor intensity shows Strong
148
+ expect(page.locator("#odor_intensity")).to_contain_text("Strong")
tests/test_e2e_workflow.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """E2E tests for complete assessment workflow."""
2
+
3
+ import pytest
4
+ from playwright.sync_api import Page, expect
5
+
6
+
7
+ def select_sample(page: Page, sample_text: str):
8
+ """Helper to select a sample from the dropdown."""
9
+ page.locator("#sample_dropdown input[role='listbox']").click()
10
+ page.wait_for_timeout(300)
11
+ page.locator(f"[role='option']:has-text('{sample_text}')").click()
12
+ # Wait for form to be populated - check that project_name has a value
13
+ # (status gets cleared when dropdown resets, so we can't rely on it)
14
+ page.wait_for_function(
15
+ """() => {
16
+ const textarea = document.querySelector('#project_name textarea');
17
+ return textarea && textarea.value && textarea.value.includes('Sample:');
18
+ }""",
19
+ timeout=30000
20
+ )
21
+
22
+
23
+ class TestFullWorkflow:
24
+ """Test complete assessment generation workflow."""
25
+
26
+ def test_generate_with_sample(self, page: Page):
27
+ """Test full generation workflow with sample data."""
28
+ select_sample(page, "Bar & Dining Area")
29
+
30
+ # Navigate to Results tab
31
+ page.locator("#tab-results-button").click()
32
+ page.wait_for_timeout(500)
33
+
34
+ # Check preflight shows ready
35
+ expect(page.locator("#preflight_status")).to_contain_text("Ready to Generate")
36
+
37
+ # Click generate
38
+ page.locator("#generate_btn").click()
39
+
40
+ # Wait for generation - check for "Complete" in the status textbox input
41
+ # The processing_status is a Textbox, so we need to check the input value
42
+ page.wait_for_function(
43
+ """() => {
44
+ const input = document.querySelector('#processing_status input, #processing_status textarea');
45
+ return input && input.value && input.value.includes('Complete');
46
+ }""",
47
+ timeout=120000
48
+ )
49
+
50
+ # Verify outputs are visible
51
+ expect(page.locator("#sow_output")).to_be_visible()
52
+ expect(page.locator("#download_md")).to_be_visible()
53
+
54
+ def test_preflight_check_incomplete_session(self, page: Page):
55
+ """Test preflight shows errors for incomplete session."""
56
+ # Don't load sample - go directly to Results
57
+ page.locator("#tab-results-button").click()
58
+ page.wait_for_timeout(500)
59
+
60
+ # Should show cannot generate message
61
+ expect(page.locator("#preflight_status")).to_contain_text("Cannot Generate")
62
+
63
+
64
+ class TestTabNavigation:
65
+ """Test tab navigation and validation."""
66
+
67
+ def test_click_tab_navigation(self, page: Page):
68
+ """Test clicking tab buttons navigates correctly."""
69
+ # Start on Tab 1 (Project)
70
+ expect(page.locator("#project_name")).to_be_visible()
71
+
72
+ # Click Tab 2 (Rooms)
73
+ page.locator("#tab-rooms-button").click()
74
+ page.wait_for_timeout(300)
75
+ expect(page.locator("#room_name")).to_be_visible()
76
+
77
+ # Click Tab 3 (Images)
78
+ page.locator("#tab-images-button").click()
79
+ page.wait_for_timeout(300)
80
+ expect(page.locator("#image_upload")).to_be_visible()
81
+
82
+ # Click Tab 4 (Observations)
83
+ page.locator("#tab-observations-button").click()
84
+ page.wait_for_timeout(300)
85
+ expect(page.locator("#smoke_odor")).to_be_visible()
86
+
87
+ # Click Tab 5 (Results)
88
+ page.locator("#tab-results-button").click()
89
+ page.wait_for_timeout(300)
90
+ expect(page.locator("#generate_btn")).to_be_visible()
91
+
92
+ def test_keyboard_shortcuts(self, page: Page):
93
+ """Test Ctrl+1-5 keyboard shortcuts."""
94
+ # Start on Tab 1
95
+ expect(page.locator("#project_name")).to_be_visible()
96
+
97
+ # Ctrl+3 -> Images tab
98
+ page.keyboard.press("Control+3")
99
+ page.wait_for_timeout(300)
100
+ expect(page.locator("#image_upload")).to_be_visible()
101
+
102
+ # Ctrl+5 -> Results tab
103
+ page.keyboard.press("Control+5")
104
+ page.wait_for_timeout(300)
105
+ expect(page.locator("#generate_btn")).to_be_visible()
106
+
107
+ # Ctrl+1 -> Back to Project
108
+ page.keyboard.press("Control+1")
109
+ page.wait_for_timeout(300)
110
+ expect(page.locator("#project_name")).to_be_visible()
111
+
112
+ def test_tab1_validation_prevents_navigation(self, page: Page):
113
+ """Test that incomplete Tab 1 shows validation errors."""
114
+ # Try to validate empty Tab 1
115
+ page.get_by_role("button", name="Validate & Continue to Rooms").click()
116
+ page.wait_for_timeout(500)
117
+
118
+ # Should show validation error message with required field errors
119
+ validation = page.locator("#tab1_validation")
120
+ expect(validation).to_contain_text("Please fix the following")
121
+
122
+
123
+ class TestBackNavigation:
124
+ """Test back button navigation."""
125
+
126
+ def test_back_from_rooms_to_project(self, page: Page):
127
+ """Test back button from Rooms tab."""
128
+ # Go to Rooms tab
129
+ page.locator("#tab-rooms-button").click()
130
+ page.wait_for_timeout(300)
131
+
132
+ # Click back
133
+ page.get_by_role("button", name="Back to Project").click()
134
+ page.wait_for_timeout(300)
135
+
136
+ # Should be on Project tab
137
+ expect(page.locator("#project_name")).to_be_visible()
138
+
139
+ def test_back_from_images_to_rooms(self, page: Page):
140
+ """Test back button from Images tab."""
141
+ # Go to Images tab
142
+ page.locator("#tab-images-button").click()
143
+ page.wait_for_timeout(300)
144
+
145
+ # Click back
146
+ page.get_by_role("button", name="Back to Rooms").click()
147
+ page.wait_for_timeout(300)
148
+
149
+ # Should be on Rooms tab
150
+ expect(page.locator("#room_name")).to_be_visible()
tests/test_samples.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for sample room data module."""
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+
6
+ from ui.samples import (
7
+ SAMPLE_SCENARIOS,
8
+ SAMPLE_SCENARIOS_BY_ID,
9
+ SAMPLE_IMAGES_DIR,
10
+ get_sample_choices,
11
+ load_sample,
12
+ load_sample_images,
13
+ get_scenario_by_id,
14
+ )
15
+ from ui.state import SessionState
16
+ from ui.components import image_store
17
+
18
+
19
+ class TestSampleScenarios:
20
+ """Test sample scenario definitions."""
21
+
22
+ def test_four_scenarios_defined(self):
23
+ """Verify exactly 4 sample scenarios are defined."""
24
+ assert len(SAMPLE_SCENARIOS) == 4
25
+
26
+ def test_scenario_ids_unique(self):
27
+ """Verify all scenario IDs are unique."""
28
+ ids = [s.id for s in SAMPLE_SCENARIOS]
29
+ assert len(ids) == len(set(ids))
30
+
31
+ def test_scenario_ids_expected(self):
32
+ """Verify expected scenario IDs exist."""
33
+ expected_ids = {"bar_dining", "bar_area", "kitchen", "factory"}
34
+ actual_ids = set(SAMPLE_SCENARIOS_BY_ID.keys())
35
+ assert actual_ids == expected_ids
36
+
37
+ def test_all_scenarios_have_required_fields(self):
38
+ """Verify all scenarios have required data fields."""
39
+ for scenario in SAMPLE_SCENARIOS:
40
+ # Basic fields
41
+ assert scenario.id
42
+ assert scenario.name
43
+ assert scenario.description
44
+ assert scenario.image_files
45
+
46
+ # Project data required fields
47
+ assert "project_name" in scenario.project_data
48
+ assert "address" in scenario.project_data
49
+ assert "city" in scenario.project_data
50
+ assert "state" in scenario.project_data
51
+ assert "zip_code" in scenario.project_data
52
+ assert "client_name" in scenario.project_data
53
+ assert "fire_date" in scenario.project_data
54
+ assert "assessment_date" in scenario.project_data
55
+ assert "facility_classification" in scenario.project_data
56
+ assert "construction_era" in scenario.project_data
57
+ assert "assessor_name" in scenario.project_data
58
+
59
+ # Room data required fields
60
+ assert "name" in scenario.room_data
61
+ assert "length_ft" in scenario.room_data
62
+ assert "width_ft" in scenario.room_data
63
+ assert "ceiling_height_ft" in scenario.room_data
64
+
65
+ # Observations should have smoke/fire odor at minimum
66
+ assert "smoke_fire_odor" in scenario.observations_data
67
+
68
+
69
+ class TestSampleImages:
70
+ """Test sample image file existence."""
71
+
72
+ def test_sample_images_dir_exists(self):
73
+ """Verify sample images directory exists."""
74
+ assert SAMPLE_IMAGES_DIR.exists()
75
+ assert SAMPLE_IMAGES_DIR.is_dir()
76
+
77
+ def test_all_referenced_images_exist(self):
78
+ """Verify all images referenced in scenarios exist on disk."""
79
+ missing_files = []
80
+ for scenario in SAMPLE_SCENARIOS:
81
+ for filename in scenario.image_files:
82
+ filepath = SAMPLE_IMAGES_DIR / filename
83
+ if not filepath.exists():
84
+ missing_files.append(f"{scenario.id}: {filename}")
85
+
86
+ assert not missing_files, f"Missing image files: {missing_files}"
87
+
88
+ def test_bar_dining_has_3_images(self):
89
+ """Verify Bar & Dining scenario has 3 images."""
90
+ scenario = SAMPLE_SCENARIOS_BY_ID["bar_dining"]
91
+ assert len(scenario.image_files) == 3
92
+
93
+ def test_bar_area_has_3_images(self):
94
+ """Verify Bar Area scenario has 3 images."""
95
+ scenario = SAMPLE_SCENARIOS_BY_ID["bar_area"]
96
+ assert len(scenario.image_files) == 3
97
+
98
+ def test_kitchen_has_6_images(self):
99
+ """Verify Kitchen scenario has 6 images."""
100
+ scenario = SAMPLE_SCENARIOS_BY_ID["kitchen"]
101
+ assert len(scenario.image_files) == 6
102
+
103
+ def test_factory_has_1_image(self):
104
+ """Verify Factory scenario has 1 image."""
105
+ scenario = SAMPLE_SCENARIOS_BY_ID["factory"]
106
+ assert len(scenario.image_files) == 1
107
+
108
+
109
+ class TestGetSampleChoices:
110
+ """Test get_sample_choices function."""
111
+
112
+ def test_returns_list_of_tuples(self):
113
+ """Verify function returns list of (label, value) tuples."""
114
+ choices = get_sample_choices()
115
+ assert isinstance(choices, list)
116
+ for choice in choices:
117
+ assert isinstance(choice, tuple)
118
+ assert len(choice) == 2
119
+
120
+ def test_first_choice_is_placeholder(self):
121
+ """Verify first choice is the placeholder."""
122
+ choices = get_sample_choices()
123
+ label, value = choices[0]
124
+ assert "Select" in label
125
+ assert value == ""
126
+
127
+ def test_returns_5_choices(self):
128
+ """Verify returns 5 choices (1 placeholder + 4 scenarios)."""
129
+ choices = get_sample_choices()
130
+ assert len(choices) == 5
131
+
132
+ def test_all_scenario_ids_in_choices(self):
133
+ """Verify all scenario IDs appear in choices."""
134
+ choices = get_sample_choices()
135
+ values = [v for _, v in choices]
136
+ assert "bar_dining" in values
137
+ assert "bar_area" in values
138
+ assert "kitchen" in values
139
+ assert "factory" in values
140
+
141
+
142
+ class TestLoadSample:
143
+ """Test load_sample function."""
144
+
145
+ def test_load_valid_scenario_returns_session(self):
146
+ """Verify loading valid scenario returns SessionState."""
147
+ session = load_sample("bar_dining")
148
+ assert session is not None
149
+ assert isinstance(session, SessionState)
150
+
151
+ # Cleanup
152
+ image_store.clear()
153
+
154
+ def test_load_invalid_scenario_returns_none(self):
155
+ """Verify loading invalid scenario returns None."""
156
+ session = load_sample("nonexistent_scenario")
157
+ assert session is None
158
+
159
+ def test_loaded_session_has_project_data(self):
160
+ """Verify loaded session has correct project data."""
161
+ session = load_sample("bar_dining")
162
+ assert session.project.project_name == "Sample: Bar & Dining Fire Assessment"
163
+ assert session.project.city == "Springfield"
164
+ assert session.project.state == "IL"
165
+
166
+ # Cleanup
167
+ image_store.clear()
168
+
169
+ def test_loaded_session_has_room(self):
170
+ """Verify loaded session has room data."""
171
+ session = load_sample("kitchen")
172
+ assert len(session.rooms) == 1
173
+ assert session.rooms[0].name == "Commercial Kitchen"
174
+ assert session.rooms[0].length_ft == 30.0
175
+ assert session.rooms[0].width_ft == 25.0
176
+
177
+ # Cleanup
178
+ image_store.clear()
179
+
180
+ def test_loaded_session_has_images(self):
181
+ """Verify loaded session has images loaded into store."""
182
+ session = load_sample("bar_area")
183
+ assert len(session.images) == 3
184
+
185
+ # Verify images are in store
186
+ for img in session.images:
187
+ assert image_store.get(img.id) is not None
188
+
189
+ # Cleanup
190
+ image_store.clear()
191
+
192
+ def test_loaded_session_has_observations(self):
193
+ """Verify loaded session has observations data."""
194
+ session = load_sample("factory")
195
+ assert session.observations.smoke_fire_odor is True
196
+ assert session.observations.odor_intensity == "strong"
197
+ assert session.observations.large_char_particles is True
198
+
199
+ # Cleanup
200
+ image_store.clear()
201
+
202
+ def test_loaded_session_tabs_marked_complete(self):
203
+ """Verify loaded session has tabs marked as complete."""
204
+ session = load_sample("bar_dining")
205
+ assert session.tab1_complete is True
206
+ assert session.tab2_complete is True
207
+ assert session.tab3_complete is True
208
+ assert session.tab4_complete is True
209
+
210
+ # Cleanup
211
+ image_store.clear()
212
+
213
+ def test_facility_classification_correct(self):
214
+ """Verify facility classifications are set correctly."""
215
+ # Restaurant scenarios should be non-operational
216
+ session = load_sample("bar_dining")
217
+ assert session.project.facility_classification == "non-operational"
218
+ image_store.clear()
219
+
220
+ # Factory should be operational
221
+ session = load_sample("factory")
222
+ assert session.project.facility_classification == "operational"
223
+ image_store.clear()
224
+
225
+ def test_construction_era_correct(self):
226
+ """Verify construction eras are set correctly."""
227
+ # Bar scenarios should be pre-1980
228
+ session = load_sample("bar_area")
229
+ assert session.project.construction_era == "pre-1980"
230
+ image_store.clear()
231
+
232
+ # Kitchen should be 1980-2000
233
+ session = load_sample("kitchen")
234
+ assert session.project.construction_era == "1980-2000"
235
+ image_store.clear()
236
+
237
+
238
+ class TestGetScenarioById:
239
+ """Test get_scenario_by_id function."""
240
+
241
+ def test_returns_scenario_for_valid_id(self):
242
+ """Verify returns scenario for valid ID."""
243
+ scenario = get_scenario_by_id("kitchen")
244
+ assert scenario is not None
245
+ assert scenario.id == "kitchen"
246
+ assert scenario.name == "Kitchen"
247
+
248
+ def test_returns_none_for_invalid_id(self):
249
+ """Verify returns None for invalid ID."""
250
+ scenario = get_scenario_by_id("invalid_id")
251
+ assert scenario is None
252
+
253
+
254
+ class TestLoadSampleImages:
255
+ """Test load_sample_images function."""
256
+
257
+ def test_loads_images_into_store(self):
258
+ """Verify images are loaded into image_store."""
259
+ scenario = SAMPLE_SCENARIOS_BY_ID["bar_dining"]
260
+ room_id = "test-room-123"
261
+
262
+ image_metas = load_sample_images(scenario, room_id)
263
+
264
+ assert len(image_metas) == 3
265
+ for meta in image_metas:
266
+ assert meta.room_id == room_id
267
+ assert image_store.get(meta.id) is not None
268
+
269
+ # Cleanup
270
+ image_store.clear()
271
+
272
+ def test_image_metadata_has_correct_room_id(self):
273
+ """Verify image metadata has correct room ID."""
274
+ scenario = SAMPLE_SCENARIOS_BY_ID["factory"]
275
+ room_id = "factory-room-456"
276
+
277
+ image_metas = load_sample_images(scenario, room_id)
278
+
279
+ assert len(image_metas) == 1
280
+ assert image_metas[0].room_id == room_id
281
+
282
+ # Cleanup
283
+ image_store.clear()
284
+
285
+ def test_image_ids_are_unique(self):
286
+ """Verify each loaded image gets a unique ID."""
287
+ scenario = SAMPLE_SCENARIOS_BY_ID["kitchen"]
288
+ room_id = "kitchen-room"
289
+
290
+ image_metas = load_sample_images(scenario, room_id)
291
+
292
+ ids = [meta.id for meta in image_metas]
293
+ assert len(ids) == len(set(ids)) # All unique
294
+
295
+ # Cleanup
296
+ image_store.clear()
tests/test_tabs.py CHANGED
@@ -27,7 +27,7 @@ class TestProjectTab:
27
  facility_classification="Operational",
28
  construction_era="Pre-1980",
29
  assessor_name="John Smith",
30
- assessor_credentials="CIH",
31
  )
32
 
33
  assert session.project.project_name == "Test Project"
@@ -49,10 +49,10 @@ class TestProjectTab:
49
  facility_classification="Non-Operational",
50
  construction_era="Post-2000",
51
  assessor_name="Name",
52
- assessor_credentials="",
53
  )
54
 
55
- assert tab_index == 0 # Stay on tab
56
  assert "Project name is required" in html
57
  assert session.tab1_complete is False
58
 
@@ -71,10 +71,10 @@ class TestProjectTab:
71
  facility_classification="Non-Operational",
72
  construction_era="Post-2000",
73
  assessor_name="Name",
74
- assessor_credentials="",
75
  )
76
 
77
- assert tab_index == 1 # Go to next tab
78
  assert "✓" in html
79
  assert session.tab1_complete is True
80
 
@@ -100,10 +100,11 @@ class TestRoomsTab:
100
  result = rooms.add_room(
101
  session,
102
  name="Room 1",
103
- floor="Ground",
104
  length=100.0,
105
  width=50.0,
106
- height=20.0,
 
107
  )
108
 
109
  session = result[0]
@@ -121,10 +122,11 @@ class TestRoomsTab:
121
  result = rooms.add_room(
122
  session,
123
  name="", # Missing
124
- floor="",
125
  length=0, # Invalid
126
  width=50.0,
127
- height=20.0,
 
128
  )
129
 
130
  session = result[0]
@@ -151,23 +153,35 @@ class TestRoomsTab:
151
 
152
  session, html, tab_index = rooms.validate_and_continue(session)
153
 
154
- assert tab_index == 2 # Go to Images tab
155
  assert session.tab2_complete is True
156
 
157
 
 
 
 
 
 
 
 
158
  class TestImagesTab:
159
  """Test Tab 3: Images."""
160
 
161
- def test_add_image_valid(self):
162
  session = SessionState()
163
  session.rooms.append(RoomFormData(id="room-001", name="Room 1", length_ft=100, width_ft=50, ceiling_height_ft=20))
164
 
165
- # Create a test image
166
  test_image = Image.new("RGB", (100, 100), color="red")
 
 
 
 
 
167
 
168
  result = images.add_image(
169
  session,
170
- image=test_image,
171
  room_id="room-001",
172
  description="Test image",
173
  )
@@ -185,13 +199,19 @@ class TestImagesTab:
185
  # Cleanup
186
  image_store.clear()
187
 
188
- def test_add_image_no_room(self):
189
  session = SessionState()
 
 
190
  test_image = Image.new("RGB", (100, 100), color="red")
 
 
 
 
191
 
192
  result = images.add_image(
193
  session,
194
- image=test_image,
195
  room_id="", # No room selected
196
  description="",
197
  )
@@ -210,7 +230,7 @@ class TestImagesTab:
210
 
211
  session, html, tab_index = images.validate_and_continue(session)
212
 
213
- assert tab_index == 2 # Stay on Images tab
214
  assert "re-uploaded" in html
215
 
216
  def test_update_room_choices(self):
@@ -275,7 +295,7 @@ class TestObservationsTab:
275
  additional_notes="",
276
  )
277
 
278
- assert tab_index == 4 # Go to Results tab
279
  assert session.tab4_complete is True
280
 
281
  def test_load_form_from_session(self):
 
27
  facility_classification="Operational",
28
  construction_era="Pre-1980",
29
  assessor_name="John Smith",
30
+ assessor_credentials=["CIH"],
31
  )
32
 
33
  assert session.project.project_name == "Test Project"
 
49
  facility_classification="Non-Operational",
50
  construction_era="Post-2000",
51
  assessor_name="Name",
52
+ assessor_credentials=[],
53
  )
54
 
55
+ assert tab_index["selected"] == 0 # Stay on tab (Gradio update dict)
56
  assert "Project name is required" in html
57
  assert session.tab1_complete is False
58
 
 
71
  facility_classification="Non-Operational",
72
  construction_era="Post-2000",
73
  assessor_name="Name",
74
+ assessor_credentials=[],
75
  )
76
 
77
+ assert tab_index["selected"] == 1 # Go to next tab (Gradio update dict)
78
  assert "✓" in html
79
  assert session.tab1_complete is True
80
 
 
100
  result = rooms.add_room(
101
  session,
102
  name="Room 1",
103
+ floor="Ground Floor",
104
  length=100.0,
105
  width=50.0,
106
+ height_preset=20, # Using preset value
107
+ height_custom=None,
108
  )
109
 
110
  session = result[0]
 
122
  result = rooms.add_room(
123
  session,
124
  name="", # Missing
125
+ floor=None,
126
  length=0, # Invalid
127
  width=50.0,
128
+ height_preset=None, # No height selected
129
+ height_custom=None,
130
  )
131
 
132
  session = result[0]
 
153
 
154
  session, html, tab_index = rooms.validate_and_continue(session)
155
 
156
+ assert tab_index["selected"] == 2 # Go to Images tab (Gradio update dict)
157
  assert session.tab2_complete is True
158
 
159
 
160
+ class MockFile:
161
+ """Mock file object for testing gr.Files uploads."""
162
+
163
+ def __init__(self, path: str):
164
+ self.name = path
165
+
166
+
167
  class TestImagesTab:
168
  """Test Tab 3: Images."""
169
 
170
+ def test_add_image_valid(self, tmp_path):
171
  session = SessionState()
172
  session.rooms.append(RoomFormData(id="room-001", name="Room 1", length_ft=100, width_ft=50, ceiling_height_ft=20))
173
 
174
+ # Create a test image file
175
  test_image = Image.new("RGB", (100, 100), color="red")
176
+ img_path = tmp_path / "test_image.png"
177
+ test_image.save(img_path)
178
+
179
+ # Create mock file object
180
+ mock_file = MockFile(str(img_path))
181
 
182
  result = images.add_image(
183
  session,
184
+ files=[mock_file],
185
  room_id="room-001",
186
  description="Test image",
187
  )
 
199
  # Cleanup
200
  image_store.clear()
201
 
202
+ def test_add_image_no_room(self, tmp_path):
203
  session = SessionState()
204
+
205
+ # Create a test image file
206
  test_image = Image.new("RGB", (100, 100), color="red")
207
+ img_path = tmp_path / "test_image.png"
208
+ test_image.save(img_path)
209
+
210
+ mock_file = MockFile(str(img_path))
211
 
212
  result = images.add_image(
213
  session,
214
+ files=[mock_file],
215
  room_id="", # No room selected
216
  description="",
217
  )
 
230
 
231
  session, html, tab_index = images.validate_and_continue(session)
232
 
233
+ assert tab_index["selected"] == 2 # Stay on Images tab (Gradio update dict)
234
  assert "re-uploaded" in html
235
 
236
  def test_update_room_choices(self):
 
295
  additional_notes="",
296
  )
297
 
298
+ assert tab_index["selected"] == 4 # Go to Results tab (Gradio update dict)
299
  assert session.tab4_complete is True
300
 
301
  def test_load_form_from_session(self):
ui/constants.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI constants for dropdowns and validation.
2
+
3
+ Centralized dropdown options for the FDAM AI Pipeline frontend.
4
+ """
5
+
6
+ # US States and Territories (display name, abbreviation)
7
+ US_STATES: list[tuple[str, str]] = [
8
+ ("Alabama", "AL"),
9
+ ("Alaska", "AK"),
10
+ ("Arizona", "AZ"),
11
+ ("Arkansas", "AR"),
12
+ ("California", "CA"),
13
+ ("Colorado", "CO"),
14
+ ("Connecticut", "CT"),
15
+ ("Delaware", "DE"),
16
+ ("District of Columbia", "DC"),
17
+ ("Florida", "FL"),
18
+ ("Georgia", "GA"),
19
+ ("Hawaii", "HI"),
20
+ ("Idaho", "ID"),
21
+ ("Illinois", "IL"),
22
+ ("Indiana", "IN"),
23
+ ("Iowa", "IA"),
24
+ ("Kansas", "KS"),
25
+ ("Kentucky", "KY"),
26
+ ("Louisiana", "LA"),
27
+ ("Maine", "ME"),
28
+ ("Maryland", "MD"),
29
+ ("Massachusetts", "MA"),
30
+ ("Michigan", "MI"),
31
+ ("Minnesota", "MN"),
32
+ ("Mississippi", "MS"),
33
+ ("Missouri", "MO"),
34
+ ("Montana", "MT"),
35
+ ("Nebraska", "NE"),
36
+ ("Nevada", "NV"),
37
+ ("New Hampshire", "NH"),
38
+ ("New Jersey", "NJ"),
39
+ ("New Mexico", "NM"),
40
+ ("New York", "NY"),
41
+ ("North Carolina", "NC"),
42
+ ("North Dakota", "ND"),
43
+ ("Ohio", "OH"),
44
+ ("Oklahoma", "OK"),
45
+ ("Oregon", "OR"),
46
+ ("Pennsylvania", "PA"),
47
+ ("Rhode Island", "RI"),
48
+ ("South Carolina", "SC"),
49
+ ("South Dakota", "SD"),
50
+ ("Tennessee", "TN"),
51
+ ("Texas", "TX"),
52
+ ("Utah", "UT"),
53
+ ("Vermont", "VT"),
54
+ ("Virginia", "VA"),
55
+ ("Washington", "WA"),
56
+ ("West Virginia", "WV"),
57
+ ("Wisconsin", "WI"),
58
+ ("Wyoming", "WY"),
59
+ # Territories
60
+ ("American Samoa", "AS"),
61
+ ("Guam", "GU"),
62
+ ("Northern Mariana Islands", "MP"),
63
+ ("Puerto Rico", "PR"),
64
+ ("U.S. Virgin Islands", "VI"),
65
+ ]
66
+
67
+ # State abbreviation to display name mapping
68
+ STATE_ABBR_TO_NAME: dict[str, str] = {abbr: name for name, abbr in US_STATES}
69
+ STATE_NAME_TO_ABBR: dict[str, str] = {name: abbr for name, abbr in US_STATES}
70
+
71
+ # Floor options for room entry
72
+ FLOOR_OPTIONS: list[str] = [
73
+ "Basement",
74
+ "Ground Floor",
75
+ "1st Floor",
76
+ "2nd Floor",
77
+ "3rd Floor",
78
+ "4th Floor",
79
+ "5th Floor",
80
+ "6th Floor",
81
+ "7th Floor",
82
+ "8th Floor",
83
+ "9th Floor",
84
+ "10th Floor",
85
+ "Mezzanine",
86
+ "Roof",
87
+ "Other",
88
+ ]
89
+
90
+ # Ceiling height presets (display label, value in feet)
91
+ # None value indicates "Custom" option requiring manual input
92
+ CEILING_HEIGHT_PRESETS: list[tuple[str, int | None]] = [
93
+ ("8 ft", 8),
94
+ ("9 ft", 9),
95
+ ("10 ft", 10),
96
+ ("12 ft", 12),
97
+ ("14 ft", 14),
98
+ ("16 ft", 16),
99
+ ("18 ft", 18),
100
+ ("20 ft", 20),
101
+ ("24 ft", 24),
102
+ ("Custom", None),
103
+ ]
104
+
105
+ # Common IH/safety professional credentials
106
+ ASSESSOR_CREDENTIALS: list[str] = [
107
+ "CIH", # Certified Industrial Hygienist
108
+ "CSP", # Certified Safety Professional
109
+ "PE", # Professional Engineer
110
+ "QEP", # Qualified Environmental Professional
111
+ "CHMM", # Certified Hazardous Materials Manager
112
+ "OHST", # Occupational Health and Safety Technologist
113
+ "ASP", # Associate Safety Professional
114
+ "Other",
115
+ ]
116
+
117
+ # Credential display names (for UI tooltips or help text)
118
+ CREDENTIAL_DESCRIPTIONS: dict[str, str] = {
119
+ "CIH": "Certified Industrial Hygienist",
120
+ "CSP": "Certified Safety Professional",
121
+ "PE": "Professional Engineer",
122
+ "QEP": "Qualified Environmental Professional",
123
+ "CHMM": "Certified Hazardous Materials Manager",
124
+ "OHST": "Occupational Health and Safety Technologist",
125
+ "ASP": "Associate Safety Professional",
126
+ "Other": "Other certification",
127
+ }
ui/samples.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sample room data for testing the FDAM AI Pipeline.
2
+
3
+ Provides 4 pre-configured sample scenarios with complete project data,
4
+ room information, images, and qualitative observations.
5
+ """
6
+
7
+ import uuid
8
+ import io
9
+ from pathlib import Path
10
+ from dataclasses import dataclass, field
11
+
12
+ from PIL import Image
13
+
14
+ from ui.state import (
15
+ SessionState,
16
+ ProjectFormData,
17
+ RoomFormData,
18
+ ImageFormData,
19
+ ObservationsFormData,
20
+ )
21
+ from ui.components import image_store
22
+
23
+
24
+ # Path to sample images directory
25
+ SAMPLE_IMAGES_DIR = Path(__file__).parent.parent / "sample_images"
26
+
27
+
28
+ @dataclass
29
+ class SampleScenario:
30
+ """Definition of a sample fire damage scenario."""
31
+
32
+ id: str
33
+ name: str
34
+ description: str
35
+ project_data: dict
36
+ room_data: dict
37
+ observations_data: dict
38
+ image_files: list[str] = field(default_factory=list)
39
+
40
+
41
+ # --- Sample Scenario Definitions ---
42
+
43
+ SAMPLE_SCENARIOS = [
44
+ # 1. Bar & Dining Area
45
+ SampleScenario(
46
+ id="bar_dining",
47
+ name="Bar & Dining Area",
48
+ description="3 images",
49
+ project_data={
50
+ "project_name": "Sample: Bar & Dining Fire Assessment",
51
+ "address": "1234 Main Street",
52
+ "city": "Springfield",
53
+ "state": "IL",
54
+ "zip_code": "62701",
55
+ "client_name": "Sample Test Client",
56
+ "fire_date": "2024-11-15",
57
+ "assessment_date": "2024-12-01",
58
+ "facility_classification": "non-operational",
59
+ "construction_era": "pre-1980",
60
+ "assessor_name": "Test Assessor",
61
+ "assessor_credentials": ["CIH"],
62
+ },
63
+ room_data={
64
+ "name": "Bar & Dining Area",
65
+ "floor": "Ground Floor",
66
+ "length_ft": 40.0,
67
+ "width_ft": 30.0,
68
+ "ceiling_height_ft": 12.0,
69
+ },
70
+ observations_data={
71
+ "smoke_fire_odor": True,
72
+ "odor_intensity": "strong",
73
+ "visible_soot_deposits": True,
74
+ "soot_pattern_description": "Heavy soot deposits on corrugated metal ceiling, moderate wall discoloration",
75
+ "large_char_particles": True,
76
+ "char_density_estimate": "moderate",
77
+ "ash_like_residue": True,
78
+ "ash_color_texture": "Ash deposits on horizontal surfaces and upholstered furniture",
79
+ "surface_discoloration": True,
80
+ "discoloration_description": "Tan/brown soot staining on walls, yellowing on decorative elements",
81
+ "dust_loading_interference": False,
82
+ "dust_notes": "",
83
+ "wildfire_indicators": False,
84
+ "wildfire_notes": "",
85
+ "additional_notes": "",
86
+ },
87
+ image_files=[
88
+ "Bar and dining area1.jpg",
89
+ "Bar and dining area2.jpg",
90
+ "Bar and dining area3.jpg",
91
+ ],
92
+ ),
93
+ # 2. Bar Area
94
+ SampleScenario(
95
+ id="bar_area",
96
+ name="Bar Area",
97
+ description="3 images",
98
+ project_data={
99
+ "project_name": "Sample: Bar Area Fire Assessment",
100
+ "address": "1234 Main Street",
101
+ "city": "Springfield",
102
+ "state": "IL",
103
+ "zip_code": "62701",
104
+ "client_name": "Sample Test Client",
105
+ "fire_date": "2024-11-15",
106
+ "assessment_date": "2024-12-01",
107
+ "facility_classification": "non-operational",
108
+ "construction_era": "pre-1980",
109
+ "assessor_name": "Test Assessor",
110
+ "assessor_credentials": ["CIH"],
111
+ },
112
+ room_data={
113
+ "name": "Bar Area",
114
+ "floor": "Ground Floor",
115
+ "length_ft": 25.0,
116
+ "width_ft": 20.0,
117
+ "ceiling_height_ft": 14.0,
118
+ },
119
+ observations_data={
120
+ "smoke_fire_odor": True,
121
+ "odor_intensity": "strong",
122
+ "visible_soot_deposits": True,
123
+ "soot_pattern_description": "Dense black coating on ceiling/ductwork, severe overhead damage",
124
+ "large_char_particles": True,
125
+ "char_density_estimate": "dense",
126
+ "ash_like_residue": True,
127
+ "ash_color_texture": "Heavy ash on shelving and bottled goods",
128
+ "surface_discoloration": True,
129
+ "discoloration_description": "Metal oxidation, melted plastic signage, deformed ductwork",
130
+ "dust_loading_interference": False,
131
+ "dust_notes": "",
132
+ "wildfire_indicators": False,
133
+ "wildfire_notes": "",
134
+ "additional_notes": "",
135
+ },
136
+ image_files=[
137
+ "Bar area1.jpg",
138
+ "Bar area2.jpg",
139
+ "Bar area3.jpg",
140
+ ],
141
+ ),
142
+ # 3. Kitchen
143
+ SampleScenario(
144
+ id="kitchen",
145
+ name="Kitchen",
146
+ description="6 images",
147
+ project_data={
148
+ "project_name": "Sample: Kitchen Fire Assessment",
149
+ "address": "5678 Industrial Blvd",
150
+ "city": "Chicago",
151
+ "state": "IL",
152
+ "zip_code": "60601",
153
+ "client_name": "Sample Test Client",
154
+ "fire_date": "2024-10-20",
155
+ "assessment_date": "2024-11-05",
156
+ "facility_classification": "non-operational",
157
+ "construction_era": "1980-2000",
158
+ "assessor_name": "Test Assessor",
159
+ "assessor_credentials": ["CIH", "CSP"],
160
+ },
161
+ room_data={
162
+ "name": "Commercial Kitchen",
163
+ "floor": "Ground Floor",
164
+ "length_ft": 30.0,
165
+ "width_ft": 25.0,
166
+ "ceiling_height_ft": 10.0,
167
+ },
168
+ observations_data={
169
+ "smoke_fire_odor": True,
170
+ "odor_intensity": "strong",
171
+ "visible_soot_deposits": True,
172
+ "soot_pattern_description": "Heavy soot on all surfaces, ceiling collapse debris",
173
+ "large_char_particles": True,
174
+ "char_density_estimate": "dense",
175
+ "ash_like_residue": True,
176
+ "ash_color_texture": "Thick ash deposits on work surfaces, equipment heavily coated",
177
+ "surface_discoloration": True,
178
+ "discoloration_description": "Charred drywall, oxidized metal equipment, concrete staining",
179
+ "dust_loading_interference": False,
180
+ "dust_notes": "",
181
+ "wildfire_indicators": False,
182
+ "wildfire_notes": "",
183
+ "additional_notes": "",
184
+ },
185
+ image_files=[
186
+ "Kitchen 1.jpg",
187
+ "Kitchen 2.jpg",
188
+ "Kitchen 3.jpg",
189
+ "Kitchen 4.jpg",
190
+ "Kitchen 5.jpg",
191
+ "Kitchen 6.jpg",
192
+ ],
193
+ ),
194
+ # 4. Factory Area
195
+ SampleScenario(
196
+ id="factory",
197
+ name="Factory Area",
198
+ description="1 image",
199
+ project_data={
200
+ "project_name": "Sample: Factory Fire Assessment",
201
+ "address": "9999 Factory Way",
202
+ "city": "Detroit",
203
+ "state": "MI",
204
+ "zip_code": "48201",
205
+ "client_name": "Industrial Test Corp",
206
+ "fire_date": "2024-09-01",
207
+ "assessment_date": "2024-09-15",
208
+ "facility_classification": "operational",
209
+ "construction_era": "pre-1980",
210
+ "assessor_name": "Test Assessor",
211
+ "assessor_credentials": ["CIH", "PE"],
212
+ },
213
+ room_data={
214
+ "name": "Factory Production Area",
215
+ "floor": "Ground Floor",
216
+ "length_ft": 80.0,
217
+ "width_ft": 60.0,
218
+ "ceiling_height_ft": 25.0,
219
+ },
220
+ observations_data={
221
+ "smoke_fire_odor": True,
222
+ "odor_intensity": "strong",
223
+ "visible_soot_deposits": True,
224
+ "soot_pattern_description": "Complete structural compromise, deep char on all surfaces",
225
+ "large_char_particles": True,
226
+ "char_density_estimate": "dense",
227
+ "ash_like_residue": True,
228
+ "ash_color_texture": "Heavy ash coating throughout, debris accumulation",
229
+ "surface_discoloration": True,
230
+ "discoloration_description": "Extreme oxidation on metal framing, thermal spalling on concrete",
231
+ "dust_loading_interference": False,
232
+ "dust_notes": "",
233
+ "wildfire_indicators": False,
234
+ "wildfire_notes": "",
235
+ "additional_notes": "",
236
+ },
237
+ image_files=[
238
+ "factory_area.jpg",
239
+ ],
240
+ ),
241
+ ]
242
+
243
+ # Create lookup dict for fast access
244
+ SAMPLE_SCENARIOS_BY_ID = {s.id: s for s in SAMPLE_SCENARIOS}
245
+
246
+
247
+ def get_sample_choices() -> list[tuple[str, str]]:
248
+ """Get dropdown choices for sample selector.
249
+
250
+ Returns:
251
+ List of (label, value) tuples for Gradio dropdown.
252
+ """
253
+ choices = [("Select a sample scenario...", "")]
254
+ for scenario in SAMPLE_SCENARIOS:
255
+ label = f"{scenario.name} ({scenario.description})"
256
+ choices.append((label, scenario.id))
257
+ return choices
258
+
259
+
260
+ def load_sample_images(scenario: SampleScenario, room_id: str) -> list[ImageFormData]:
261
+ """Load sample images from disk into image_store.
262
+
263
+ Args:
264
+ scenario: The sample scenario to load images for.
265
+ room_id: The room ID to associate images with.
266
+
267
+ Returns:
268
+ List of ImageFormData objects for the loaded images.
269
+ """
270
+ image_metas = []
271
+
272
+ for filename in scenario.image_files:
273
+ filepath = SAMPLE_IMAGES_DIR / filename
274
+ if filepath.exists():
275
+ try:
276
+ # Read and convert image to PNG bytes
277
+ img = Image.open(filepath)
278
+ img_bytes = io.BytesIO()
279
+ img.save(img_bytes, format="PNG")
280
+
281
+ # Generate unique image ID
282
+ image_id = f"sample-{uuid.uuid4().hex[:8]}"
283
+
284
+ # Store in image_store
285
+ image_store.store(image_id, img_bytes.getvalue())
286
+
287
+ # Create metadata
288
+ image_metas.append(
289
+ ImageFormData(
290
+ id=image_id,
291
+ filename=filename,
292
+ room_id=room_id,
293
+ description=f"Sample image: {filename}",
294
+ )
295
+ )
296
+ except Exception:
297
+ # Skip files that can't be opened as images
298
+ continue
299
+
300
+ return image_metas
301
+
302
+
303
+ def load_sample(scenario_id: str) -> SessionState | None:
304
+ """Load a sample scenario into a new SessionState.
305
+
306
+ Args:
307
+ scenario_id: The ID of the scenario to load.
308
+
309
+ Returns:
310
+ A new SessionState populated with the scenario data, or None if not found.
311
+ """
312
+ scenario = SAMPLE_SCENARIOS_BY_ID.get(scenario_id)
313
+ if not scenario:
314
+ return None
315
+
316
+ # Create room with unique ID
317
+ room_id = f"room-{uuid.uuid4().hex[:8]}"
318
+ room = RoomFormData(
319
+ id=room_id,
320
+ name=scenario.room_data["name"],
321
+ floor=scenario.room_data.get("floor", ""),
322
+ length_ft=scenario.room_data["length_ft"],
323
+ width_ft=scenario.room_data["width_ft"],
324
+ ceiling_height_ft=scenario.room_data["ceiling_height_ft"],
325
+ )
326
+
327
+ # Load images
328
+ images = load_sample_images(scenario, room_id)
329
+
330
+ # Create session
331
+ session = SessionState(
332
+ project=ProjectFormData(**scenario.project_data),
333
+ rooms=[room],
334
+ images=images,
335
+ observations=ObservationsFormData(**scenario.observations_data),
336
+ name=scenario.project_data["project_name"],
337
+ )
338
+
339
+ # Mark tabs as complete since we have all data
340
+ session.tab1_complete = True
341
+ session.tab2_complete = True
342
+ session.tab3_complete = len(images) > 0
343
+ session.tab4_complete = True
344
+
345
+ return session
346
+
347
+
348
+ def get_scenario_by_id(scenario_id: str) -> SampleScenario | None:
349
+ """Get a sample scenario by its ID.
350
+
351
+ Args:
352
+ scenario_id: The scenario ID.
353
+
354
+ Returns:
355
+ The SampleScenario object or None if not found.
356
+ """
357
+ return SAMPLE_SCENARIOS_BY_ID.get(scenario_id)
ui/state.py CHANGED
@@ -36,7 +36,7 @@ class ProjectFormData(BaseModel):
36
  facility_classification: FacilityClassification = "non-operational"
37
  construction_era: ConstructionEra = "post-2000"
38
  assessor_name: str = ""
39
- assessor_credentials: str = ""
40
 
41
 
42
  class RoomFormData(BaseModel):
@@ -253,9 +253,24 @@ def session_to_json(session: SessionState) -> str:
253
 
254
 
255
  def session_from_json(json_str: str) -> SessionState:
256
- """Deserialize session from JSON."""
 
 
 
257
  try:
258
- return SessionState.model_validate_json(json_str)
 
 
 
 
 
 
 
 
 
 
 
 
259
  except Exception:
260
  return create_new_session()
261
 
 
36
  facility_classification: FacilityClassification = "non-operational"
37
  construction_era: ConstructionEra = "post-2000"
38
  assessor_name: str = ""
39
+ assessor_credentials: list[str] = Field(default_factory=list) # Multiselect credentials
40
 
41
 
42
  class RoomFormData(BaseModel):
 
253
 
254
 
255
  def session_from_json(json_str: str) -> SessionState:
256
+ """Deserialize session from JSON.
257
+
258
+ Includes migration for old sessions where assessor_credentials was a string.
259
+ """
260
  try:
261
+ # Parse JSON first to check for migrations needed
262
+ data = json.loads(json_str)
263
+
264
+ # Migration: Convert old string credentials to list
265
+ if "project" in data and isinstance(data["project"].get("assessor_credentials"), str):
266
+ old_creds = data["project"]["assessor_credentials"]
267
+ # Convert comma-separated string to list, or empty list if empty
268
+ if old_creds:
269
+ data["project"]["assessor_credentials"] = [c.strip() for c in old_creds.split(",") if c.strip()]
270
+ else:
271
+ data["project"]["assessor_credentials"] = []
272
+
273
+ return SessionState.model_validate(data)
274
  except Exception:
275
  return create_new_session()
276
 
ui/storage.py CHANGED
@@ -170,9 +170,16 @@ async () => {
170
  """
171
 
172
 
173
- def get_head_html() -> str:
174
- """Get HTML to inject into Gradio head for localStorage support."""
175
- return LOCALSTORAGE_JS
 
 
 
 
 
 
 
176
 
177
 
178
  def create_save_trigger_js(field_updates: dict[str, str]) -> str:
 
170
  """
171
 
172
 
173
+ def get_head_html(additional_scripts: str = "") -> str:
174
+ """Get HTML to inject into Gradio head for localStorage support.
175
+
176
+ Args:
177
+ additional_scripts: Optional additional HTML/JS to include.
178
+
179
+ Returns:
180
+ Combined HTML string for head injection.
181
+ """
182
+ return LOCALSTORAGE_JS + additional_scripts
183
 
184
 
185
  def create_save_trigger_js(field_updates: dict[str, str]) -> str:
ui/tabs/images.py CHANGED
@@ -28,10 +28,10 @@ def create_tab() -> dict[str, Any]:
28
 
29
  with gr.Row():
30
  with gr.Column(scale=2):
31
- image_upload = gr.Image(
32
- label="Upload Image",
33
- type="pil",
34
- sources=["upload"],
35
  elem_id="image_upload",
36
  )
37
  room_select = gr.Dropdown(
@@ -39,15 +39,17 @@ def create_tab() -> dict[str, Any]:
39
  choices=[],
40
  value=None,
41
  elem_id="room_select",
 
42
  )
43
  image_description = gr.Textbox(
44
  label="Description (optional)",
45
  placeholder="e.g., View of ceiling deck from center aisle",
46
  elem_id="image_description",
 
47
  )
48
 
49
  with gr.Row():
50
- add_image_btn = gr.Button("Add Image", variant="primary")
51
  clear_upload_btn = gr.Button("Clear", variant="secondary")
52
 
53
  with gr.Column(scale=3):
@@ -110,26 +112,40 @@ def create_tab() -> dict[str, Any]:
110
 
111
  def add_image(
112
  session: SessionState,
113
- image: Optional[Image.Image],
114
  room_id: str,
115
  description: str,
116
- ) -> tuple[SessionState, list[tuple], str, str, None, None, str]:
117
- """Add an image to the session.
 
 
 
 
 
 
118
 
119
  Returns:
120
  Tuple of (session, gallery_data, validation_html, image_count,
121
- cleared_image, cleared_description, room_id).
122
  """
123
  validation_html = ""
124
 
125
  # Validate input
126
  errors = []
127
- if image is None:
128
- errors.append("Please upload an image")
129
  if not room_id:
130
- errors.append("Please select a room for this image")
131
- if len(session.images) >= settings.max_images_per_assessment:
132
- errors.append(f"Maximum of {settings.max_images_per_assessment} images allowed")
 
 
 
 
 
 
 
 
133
 
134
  if errors:
135
  error_items = "".join(f"<li>{e}</li>" for e in errors)
@@ -141,43 +157,66 @@ def add_image(
141
  </div>
142
  """
143
  gallery_data = _get_gallery_data(session)
144
- count_str = f"{len(session.images)} / {settings.max_images_per_assessment}"
145
- return session, gallery_data, validation_html, count_str, image, description, room_id
146
-
147
- # Generate image ID
148
- image_id = f"img-{uuid.uuid4().hex[:8]}"
149
-
150
- # Store image bytes in memory
151
- img_bytes = io.BytesIO()
152
- image.save(img_bytes, format="PNG")
153
- image_store.store(image_id, img_bytes.getvalue())
154
 
155
- # Get room name for filename
156
  room_name = "unknown"
157
  for room in session.rooms:
158
  if room.id == room_id:
159
  room_name = room.name.replace(" ", "_")[:20]
160
  break
161
 
162
- # Add image metadata to session
163
- img_meta = ImageFormData(
164
- id=image_id,
165
- filename=f"{room_name}_{image_id}.png",
166
- room_id=room_id,
167
- description=description.strip() if description else "",
168
- )
169
- session.images.append(img_meta)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  session.update_timestamp()
171
 
172
  # Success message
173
- validation_html = f"""
174
- <div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
175
- <span style="color: #2e7d32;">✓ Image added for room: {room_name}</span>
176
- </div>
177
- """
 
 
 
 
 
 
 
178
 
179
  gallery_data = _get_gallery_data(session)
180
- count_str = f"{len(session.images)} / {settings.max_images_per_assessment}"
181
 
182
  # Clear form
183
  return session, gallery_data, validation_html, count_str, None, "", room_id
@@ -246,7 +285,7 @@ def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int
246
  </p>
247
  </div>
248
  """
249
- return session, html, 2 # Stay on Images tab
250
 
251
  is_valid, errors = session.validate_tab3()
252
 
@@ -258,7 +297,7 @@ def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int
258
  <span style="color: #2e7d32;">✓ Images complete. Proceeding to Observations tab...</span>
259
  </div>
260
  """
261
- return session, html, 3 # Go to tab index 3 (Observations)
262
  else:
263
  session.tab3_complete = False
264
  error_items = "".join(f"<li>{e}</li>" for e in errors)
@@ -270,7 +309,7 @@ def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int
270
  </ul>
271
  </div>
272
  """
273
- return session, html, 2 # Stay on current tab
274
 
275
 
276
  def update_room_choices(session: SessionState) -> dict:
 
28
 
29
  with gr.Row():
30
  with gr.Column(scale=2):
31
+ image_upload = gr.Files(
32
+ label="Upload Images (select multiple)",
33
+ file_count="multiple",
34
+ file_types=["image"],
35
  elem_id="image_upload",
36
  )
37
  room_select = gr.Dropdown(
 
39
  choices=[],
40
  value=None,
41
  elem_id="room_select",
42
+ info="All uploaded images will be assigned to this room",
43
  )
44
  image_description = gr.Textbox(
45
  label="Description (optional)",
46
  placeholder="e.g., View of ceiling deck from center aisle",
47
  elem_id="image_description",
48
+ info="Applied to all images in batch",
49
  )
50
 
51
  with gr.Row():
52
+ add_image_btn = gr.Button("Add Images", variant="primary")
53
  clear_upload_btn = gr.Button("Clear", variant="secondary")
54
 
55
  with gr.Column(scale=3):
 
112
 
113
  def add_image(
114
  session: SessionState,
115
+ files: list | None,
116
  room_id: str,
117
  description: str,
118
+ ) -> tuple[SessionState, list[tuple], str, str, None, str, str]:
119
+ """Add one or more images to the session (batch upload).
120
+
121
+ Args:
122
+ session: Current session state.
123
+ files: List of uploaded file objects from gr.Files, each with a `name` attribute.
124
+ room_id: Room ID to associate images with.
125
+ description: Optional description applied to all images.
126
 
127
  Returns:
128
  Tuple of (session, gallery_data, validation_html, image_count,
129
+ cleared_files, cleared_description, room_id).
130
  """
131
  validation_html = ""
132
 
133
  # Validate input
134
  errors = []
135
+ if not files or len(files) == 0:
136
+ errors.append("Please upload at least one image")
137
  if not room_id:
138
+ errors.append("Please select a room for these images")
139
+
140
+ # Check capacity
141
+ current_count = len(session.images)
142
+ max_allowed = settings.max_images_per_assessment
143
+ if files and current_count + len(files) > max_allowed:
144
+ remaining = max_allowed - current_count
145
+ if remaining <= 0:
146
+ errors.append(f"Maximum of {max_allowed} images allowed (already at limit)")
147
+ else:
148
+ errors.append(f"Can only add {remaining} more image(s) (limit: {max_allowed})")
149
 
150
  if errors:
151
  error_items = "".join(f"<li>{e}</li>" for e in errors)
 
157
  </div>
158
  """
159
  gallery_data = _get_gallery_data(session)
160
+ count_str = f"{len(session.images)} / {max_allowed}"
161
+ return session, gallery_data, validation_html, count_str, files, description, room_id
 
 
 
 
 
 
 
 
162
 
163
+ # Get room name for filenames
164
  room_name = "unknown"
165
  for room in session.rooms:
166
  if room.id == room_id:
167
  room_name = room.name.replace(" ", "_")[:20]
168
  break
169
 
170
+ # Process each uploaded file
171
+ added_count = 0
172
+ for file_obj in files:
173
+ # Check if we've hit the limit
174
+ if len(session.images) >= max_allowed:
175
+ break
176
+
177
+ try:
178
+ # Open image from file path
179
+ img = Image.open(file_obj.name)
180
+
181
+ # Generate image ID
182
+ image_id = f"img-{uuid.uuid4().hex[:8]}"
183
+
184
+ # Store image bytes in memory
185
+ img_bytes = io.BytesIO()
186
+ img.save(img_bytes, format="PNG")
187
+ image_store.store(image_id, img_bytes.getvalue())
188
+
189
+ # Add image metadata to session
190
+ img_meta = ImageFormData(
191
+ id=image_id,
192
+ filename=f"{room_name}_{image_id}.png",
193
+ room_id=room_id,
194
+ description=description.strip() if description else "",
195
+ )
196
+ session.images.append(img_meta)
197
+ added_count += 1
198
+ except Exception:
199
+ # Skip files that can't be opened as images
200
+ continue
201
+
202
  session.update_timestamp()
203
 
204
  # Success message
205
+ if added_count > 0:
206
+ validation_html = f"""
207
+ <div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
208
+ <span style="color: #2e7d32;">✓ {added_count} image(s) added for room: {room_name}</span>
209
+ </div>
210
+ """
211
+ else:
212
+ validation_html = """
213
+ <div style="background: #fff3e0; border: 1px solid #ffb74d; border-radius: 4px; padding: 10px;">
214
+ <span style="color: #e65100;">No images could be processed</span>
215
+ </div>
216
+ """
217
 
218
  gallery_data = _get_gallery_data(session)
219
+ count_str = f"{len(session.images)} / {max_allowed}"
220
 
221
  # Clear form
222
  return session, gallery_data, validation_html, count_str, None, "", room_id
 
285
  </p>
286
  </div>
287
  """
288
+ return session, html, gr.update(selected=2) # Stay on Images tab
289
 
290
  is_valid, errors = session.validate_tab3()
291
 
 
297
  <span style="color: #2e7d32;">✓ Images complete. Proceeding to Observations tab...</span>
298
  </div>
299
  """
300
+ return session, html, gr.update(selected=3) # Go to tab index 3 (Observations)
301
  else:
302
  session.tab3_complete = False
303
  error_items = "".join(f"<li>{e}</li>" for e in errors)
 
309
  </ul>
310
  </div>
311
  """
312
+ return session, html, gr.update(selected=2) # Stay on current tab
313
 
314
 
315
  def update_room_choices(session: SessionState) -> dict:
ui/tabs/observations.py CHANGED
@@ -252,7 +252,7 @@ def validate_and_continue(
252
  <span style="color: #2e7d32;">✓ Observations saved. Proceeding to Generate Results...</span>
253
  </div>
254
  """
255
- return session, html, 4 # Go to tab index 4 (Results)
256
 
257
 
258
  def load_form_from_session(session: SessionState) -> tuple:
 
252
  <span style="color: #2e7d32;">✓ Observations saved. Proceeding to Generate Results...</span>
253
  </div>
254
  """
255
+ return session, html, gr.update(selected=4) # Go to tab index 4 (Results)
256
 
257
 
258
  def load_form_from_session(session: SessionState) -> tuple:
ui/tabs/project.py CHANGED
@@ -3,10 +3,16 @@
3
  Collects project details, client information, and facility classification.
4
  """
5
 
 
6
  import gradio as gr
7
  from typing import Any
8
 
9
  from ui.state import SessionState, ProjectFormData
 
 
 
 
 
10
 
11
 
12
  # Map UI values to schema values
@@ -47,30 +53,39 @@ def create_tab() -> dict[str, Any]:
47
  )
48
  with gr.Row():
49
  city = gr.Textbox(label="City *", elem_id="city")
50
- state = gr.Textbox(
51
  label="State *",
52
- max_lines=1,
53
  elem_id="state",
 
54
  )
55
- zip_code = gr.Textbox(
56
- label="ZIP Code *",
57
- max_lines=1,
58
- elem_id="zip_code",
59
- )
 
 
 
 
 
 
60
 
61
  with gr.Column():
62
  client_name = gr.Textbox(
63
  label="Client Name *",
64
  elem_id="client_name",
65
  )
66
- fire_date = gr.Textbox(
67
  label="Fire Date *",
68
- placeholder="YYYY-MM-DD",
 
69
  elem_id="fire_date",
70
  )
71
- assessment_date = gr.Textbox(
72
  label="Assessment Date *",
73
- placeholder="YYYY-MM-DD",
 
74
  elem_id="assessment_date",
75
  )
76
 
@@ -95,10 +110,12 @@ def create_tab() -> dict[str, Any]:
95
  label="Assessor Name *",
96
  elem_id="assessor_name",
97
  )
98
- assessor_credentials = gr.Textbox(
99
  label="Credentials (optional)",
100
- placeholder="CIH, CSP, etc.",
 
101
  elem_id="assessor_credentials",
 
102
  )
103
 
104
  # Validation status display
@@ -120,6 +137,7 @@ def create_tab() -> dict[str, Any]:
120
  "city": city,
121
  "state": state,
122
  "zip_code": zip_code,
 
123
  "client_name": client_name,
124
  "fire_date": fire_date,
125
  "assessment_date": assessment_date,
@@ -145,7 +163,7 @@ def update_session_from_form(
145
  facility_classification: str,
146
  construction_era: str,
147
  assessor_name: str,
148
- assessor_credentials: str,
149
  ) -> SessionState:
150
  """Update session state from form values."""
151
  session.project = ProjectFormData(
@@ -160,7 +178,7 @@ def update_session_from_form(
160
  facility_classification=FACILITY_MAP.get(facility_classification, "non-operational"),
161
  construction_era=ERA_MAP.get(construction_era, "post-2000"),
162
  assessor_name=assessor_name or "",
163
- assessor_credentials=assessor_credentials or "",
164
  )
165
  session.update_timestamp()
166
  return session
@@ -179,7 +197,7 @@ def validate_and_continue(
179
  facility_classification: str,
180
  construction_era: str,
181
  assessor_name: str,
182
- assessor_credentials: str,
183
  ) -> tuple[SessionState, str, int]:
184
  """Validate Tab 1 and update session.
185
 
@@ -213,7 +231,7 @@ def validate_and_continue(
213
  <span style="color: #2e7d32;">✓ Project information complete. Proceeding to Rooms tab...</span>
214
  </div>
215
  """
216
- return session, html, 1 # Go to tab index 1 (Rooms)
217
  else:
218
  session.tab1_complete = False
219
  error_items = "".join(f"<li>{e}</li>" for e in errors)
@@ -225,7 +243,7 @@ def validate_and_continue(
225
  </ul>
226
  </div>
227
  """
228
- return session, html, 0 # Stay on current tab
229
 
230
 
231
  def load_form_from_session(session: SessionState) -> tuple:
@@ -249,3 +267,21 @@ def load_form_from_session(session: SessionState) -> tuple:
249
  p.assessor_name,
250
  p.assessor_credentials,
251
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  Collects project details, client information, and facility classification.
4
  """
5
 
6
+ import re
7
  import gradio as gr
8
  from typing import Any
9
 
10
  from ui.state import SessionState, ProjectFormData
11
+ from ui.constants import US_STATES, ASSESSOR_CREDENTIALS
12
+
13
+
14
+ # ZIP code validation regex (5 digits or 5+4 format)
15
+ ZIP_PATTERN = re.compile(r"^\d{5}(-\d{4})?$")
16
 
17
 
18
  # Map UI values to schema values
 
53
  )
54
  with gr.Row():
55
  city = gr.Textbox(label="City *", elem_id="city")
56
+ state = gr.Dropdown(
57
  label="State *",
58
+ choices=US_STATES,
59
  elem_id="state",
60
+ allow_custom_value=True, # Allow empty value for validation
61
  )
62
+ with gr.Column(scale=1):
63
+ zip_code = gr.Textbox(
64
+ label="ZIP Code *",
65
+ max_lines=1,
66
+ elem_id="zip_code",
67
+ info="Format: 12345 or 12345-6789",
68
+ )
69
+ zip_validation = gr.HTML(
70
+ value="",
71
+ elem_id="zip_validation",
72
+ )
73
 
74
  with gr.Column():
75
  client_name = gr.Textbox(
76
  label="Client Name *",
77
  elem_id="client_name",
78
  )
79
+ fire_date = gr.DateTime(
80
  label="Fire Date *",
81
+ include_time=False,
82
+ type="string",
83
  elem_id="fire_date",
84
  )
85
+ assessment_date = gr.DateTime(
86
  label="Assessment Date *",
87
+ include_time=False,
88
+ type="string",
89
  elem_id="assessment_date",
90
  )
91
 
 
110
  label="Assessor Name *",
111
  elem_id="assessor_name",
112
  )
113
+ assessor_credentials = gr.Dropdown(
114
  label="Credentials (optional)",
115
+ choices=ASSESSOR_CREDENTIALS,
116
+ multiselect=True,
117
  elem_id="assessor_credentials",
118
+ info="Select all that apply",
119
  )
120
 
121
  # Validation status display
 
137
  "city": city,
138
  "state": state,
139
  "zip_code": zip_code,
140
+ "zip_validation": zip_validation,
141
  "client_name": client_name,
142
  "fire_date": fire_date,
143
  "assessment_date": assessment_date,
 
163
  facility_classification: str,
164
  construction_era: str,
165
  assessor_name: str,
166
+ assessor_credentials: list[str] | None,
167
  ) -> SessionState:
168
  """Update session state from form values."""
169
  session.project = ProjectFormData(
 
178
  facility_classification=FACILITY_MAP.get(facility_classification, "non-operational"),
179
  construction_era=ERA_MAP.get(construction_era, "post-2000"),
180
  assessor_name=assessor_name or "",
181
+ assessor_credentials=assessor_credentials or [],
182
  )
183
  session.update_timestamp()
184
  return session
 
197
  facility_classification: str,
198
  construction_era: str,
199
  assessor_name: str,
200
+ assessor_credentials: list[str] | None,
201
  ) -> tuple[SessionState, str, int]:
202
  """Validate Tab 1 and update session.
203
 
 
231
  <span style="color: #2e7d32;">✓ Project information complete. Proceeding to Rooms tab...</span>
232
  </div>
233
  """
234
+ return session, html, gr.update(selected=1) # Go to tab index 1 (Rooms)
235
  else:
236
  session.tab1_complete = False
237
  error_items = "".join(f"<li>{e}</li>" for e in errors)
 
243
  </ul>
244
  </div>
245
  """
246
+ return session, html, gr.update(selected=0) # Stay on current tab
247
 
248
 
249
  def load_form_from_session(session: SessionState) -> tuple:
 
267
  p.assessor_name,
268
  p.assessor_credentials,
269
  )
270
+
271
+
272
+ def validate_zip_format(zip_code: str) -> str:
273
+ """Validate ZIP code format and return validation HTML indicator.
274
+
275
+ Args:
276
+ zip_code: The ZIP code string to validate.
277
+
278
+ Returns:
279
+ HTML string with validation indicator (green check or red X).
280
+ """
281
+ if not zip_code:
282
+ return "" # Empty - no indicator
283
+
284
+ if ZIP_PATTERN.match(zip_code.strip()):
285
+ return '<span style="color: #2e7d32; font-size: 12px;">✓ Valid format</span>'
286
+ else:
287
+ return '<span style="color: #c62828; font-size: 12px;">✗ Use format: 12345 or 12345-6789</span>'
ui/tabs/rooms.py CHANGED
@@ -9,6 +9,7 @@ from typing import Any
9
 
10
  from ui.state import SessionState, RoomFormData
11
  from ui.components import create_room_table_data
 
12
 
13
 
14
  def create_tab() -> dict[str, Any]:
@@ -27,9 +28,9 @@ def create_tab() -> dict[str, Any]:
27
  placeholder="e.g., Warehouse Bay A",
28
  elem_id="room_name",
29
  )
30
- room_floor = gr.Textbox(
31
  label="Floor (optional)",
32
- placeholder="e.g., Ground Floor, 2nd Floor",
33
  elem_id="room_floor",
34
  )
35
  with gr.Row():
@@ -45,11 +46,19 @@ def create_tab() -> dict[str, Any]:
45
  value=None,
46
  elem_id="room_width",
47
  )
48
- room_height = gr.Number(
49
- label="Ceiling Height (ft) *",
 
 
 
 
 
 
 
50
  minimum=1,
51
  value=None,
52
- elem_id="room_height",
 
53
  )
54
 
55
  with gr.Row():
@@ -104,7 +113,8 @@ def create_tab() -> dict[str, Any]:
104
  "room_floor": room_floor,
105
  "room_length": room_length,
106
  "room_width": room_width,
107
- "room_height": room_height,
 
108
  "add_room_btn": add_room_btn,
109
  "clear_form_btn": clear_form_btn,
110
  "rooms_table": rooms_table,
@@ -122,19 +132,29 @@ def create_tab() -> dict[str, Any]:
122
  def add_room(
123
  session: SessionState,
124
  name: str,
125
- floor: str,
126
  length: float,
127
  width: float,
128
- height: float,
129
- ) -> tuple[SessionState, list[list], str, str, str, str, str, float, float, float]:
 
130
  """Add a room to the session.
131
 
132
  Returns:
133
  Tuple of (session, table_data, validation_html, room_count, total_area, total_volume,
134
- cleared_name, cleared_floor, cleared_length, cleared_width, cleared_height).
 
135
  """
136
  validation_html = ""
137
 
 
 
 
 
 
 
 
 
138
  # Validate input
139
  errors = []
140
  if not name or not name.strip():
@@ -144,7 +164,7 @@ def add_room(
144
  if not width or width <= 0:
145
  errors.append("Width must be greater than 0")
146
  if not height or height <= 0:
147
- errors.append("Ceiling height must be greater than 0")
148
 
149
  if errors:
150
  error_items = "".join(f"<li>{e}</li>" for e in errors)
@@ -166,10 +186,11 @@ def add_room(
166
  stats["area"],
167
  stats["volume"],
168
  name or "",
169
- floor or "",
170
  length,
171
  width,
172
- height,
 
173
  )
174
 
175
  # Add the room
@@ -194,7 +215,7 @@ def add_room(
194
  table_data = create_room_table_data(session)
195
  stats = _calculate_stats(session)
196
 
197
- # Clear form fields (return None for Number components)
198
  return (
199
  session,
200
  table_data,
@@ -203,13 +224,27 @@ def add_room(
203
  stats["area"],
204
  stats["volume"],
205
  "", # Clear name
206
- "", # Clear floor
207
  None, # Clear length
208
  None, # Clear width
209
- None, # Clear height
 
210
  )
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  def remove_last_room(session: SessionState) -> tuple[SessionState, list[list], str, str, str]:
214
  """Remove the last room from the session."""
215
  if session.rooms:
@@ -261,7 +296,7 @@ def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int
261
  <span style="color: #2e7d32;">✓ Rooms complete. Proceeding to Images tab...</span>
262
  </div>
263
  """
264
- return session, html, 2 # Go to tab index 2 (Images)
265
  else:
266
  session.tab2_complete = False
267
  error_items = "".join(f"<li>{e}</li>" for e in errors)
@@ -273,7 +308,7 @@ def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int
273
  </ul>
274
  </div>
275
  """
276
- return session, html, 1 # Stay on current tab
277
 
278
 
279
  def load_from_session(session: SessionState) -> tuple[list[list], str, str, str]:
 
9
 
10
  from ui.state import SessionState, RoomFormData
11
  from ui.components import create_room_table_data
12
+ from ui.constants import FLOOR_OPTIONS, CEILING_HEIGHT_PRESETS
13
 
14
 
15
  def create_tab() -> dict[str, Any]:
 
28
  placeholder="e.g., Warehouse Bay A",
29
  elem_id="room_name",
30
  )
31
+ room_floor = gr.Dropdown(
32
  label="Floor (optional)",
33
+ choices=FLOOR_OPTIONS,
34
  elem_id="room_floor",
35
  )
36
  with gr.Row():
 
46
  value=None,
47
  elem_id="room_width",
48
  )
49
+ with gr.Row():
50
+ room_height_preset = gr.Dropdown(
51
+ label="Ceiling Height *",
52
+ choices=CEILING_HEIGHT_PRESETS,
53
+ elem_id="room_height_preset",
54
+ info="Select preset or choose Custom",
55
+ )
56
+ room_height_custom = gr.Number(
57
+ label="Custom Height (ft)",
58
  minimum=1,
59
  value=None,
60
+ visible=False,
61
+ elem_id="room_height_custom",
62
  )
63
 
64
  with gr.Row():
 
113
  "room_floor": room_floor,
114
  "room_length": room_length,
115
  "room_width": room_width,
116
+ "room_height_preset": room_height_preset,
117
+ "room_height_custom": room_height_custom,
118
  "add_room_btn": add_room_btn,
119
  "clear_form_btn": clear_form_btn,
120
  "rooms_table": rooms_table,
 
132
  def add_room(
133
  session: SessionState,
134
  name: str,
135
+ floor: str | None,
136
  length: float,
137
  width: float,
138
+ height_preset: int | None,
139
+ height_custom: float | None,
140
+ ) -> tuple[SessionState, list[list], str, str, str, str, str | None, float | None, float | None, None, None]:
141
  """Add a room to the session.
142
 
143
  Returns:
144
  Tuple of (session, table_data, validation_html, room_count, total_area, total_volume,
145
+ cleared_name, cleared_floor, cleared_length, cleared_width,
146
+ cleared_height_preset, cleared_height_custom).
147
  """
148
  validation_html = ""
149
 
150
+ # Determine actual ceiling height from preset or custom
151
+ if height_preset is not None:
152
+ height = float(height_preset)
153
+ elif height_custom is not None and height_custom > 0:
154
+ height = float(height_custom)
155
+ else:
156
+ height = None
157
+
158
  # Validate input
159
  errors = []
160
  if not name or not name.strip():
 
164
  if not width or width <= 0:
165
  errors.append("Width must be greater than 0")
166
  if not height or height <= 0:
167
+ errors.append("Ceiling height is required (select preset or enter custom)")
168
 
169
  if errors:
170
  error_items = "".join(f"<li>{e}</li>" for e in errors)
 
186
  stats["area"],
187
  stats["volume"],
188
  name or "",
189
+ floor,
190
  length,
191
  width,
192
+ height_preset,
193
+ height_custom,
194
  )
195
 
196
  # Add the room
 
215
  table_data = create_room_table_data(session)
216
  stats = _calculate_stats(session)
217
 
218
+ # Clear form fields (return None for Number components, None for dropdowns)
219
  return (
220
  session,
221
  table_data,
 
224
  stats["area"],
225
  stats["volume"],
226
  "", # Clear name
227
+ None, # Clear floor dropdown
228
  None, # Clear length
229
  None, # Clear width
230
+ None, # Clear height preset
231
+ None, # Clear height custom
232
  )
233
 
234
 
235
+ def on_height_preset_change(preset_value: int | None) -> dict:
236
+ """Show/hide custom height input based on preset selection.
237
+
238
+ Args:
239
+ preset_value: The selected preset value, or None for "Custom".
240
+
241
+ Returns:
242
+ Gradio update dict for custom height visibility.
243
+ """
244
+ # If None (Custom selected), show custom input; otherwise hide it
245
+ return gr.update(visible=(preset_value is None))
246
+
247
+
248
  def remove_last_room(session: SessionState) -> tuple[SessionState, list[list], str, str, str]:
249
  """Remove the last room from the session."""
250
  if session.rooms:
 
296
  <span style="color: #2e7d32;">✓ Rooms complete. Proceeding to Images tab...</span>
297
  </div>
298
  """
299
+ return session, html, gr.update(selected=2) # Go to tab index 2 (Images)
300
  else:
301
  session.tab2_complete = False
302
  error_items = "".join(f"<li>{e}</li>" for e in errors)
 
308
  </ul>
309
  </div>
310
  """
311
+ return session, html, gr.update(selected=1) # Stay on current tab
312
 
313
 
314
  def load_from_session(session: SessionState) -> tuple[list[list], str, str, str]: