Spaces:
Paused
Paused
Commit
·
f3ebc82
1
Parent(s):
8771f89
Fix critical model implementations and add sample scenarios
Browse filesModel 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 +1 -0
- CLAUDE.md +18 -1
- app.py +193 -66
- config/inference.py +45 -6
- config/logging.py +61 -0
- config/settings.py +3 -0
- models/loader.py +22 -2
- models/mock.py +67 -15
- models/real.py +212 -61
- pipeline/calculations.py +15 -0
- pipeline/dispositions.py +8 -0
- pipeline/generator.py +9 -1
- pipeline/main.py +60 -1
- rag/retriever.py +23 -2
- rag/vectorstore.py +51 -9
- requirements-dev.txt +11 -0
- requirements.txt +1 -1
- sample_images/Bar and dining area1.jpg +3 -0
- sample_images/Bar and dining area2.jpg +3 -0
- sample_images/Bar and dining area3.jpg +3 -0
- sample_images/Bar area1.jpg +3 -0
- sample_images/Bar area2.jpg +3 -0
- sample_images/Bar area3.jpg +3 -0
- sample_images/Kitchen 1.jpg +3 -0
- sample_images/Kitchen 2.jpg +3 -0
- sample_images/Kitchen 3.jpg +3 -0
- sample_images/Kitchen 4.jpg +3 -0
- sample_images/Kitchen 5.jpg +3 -0
- sample_images/Kitchen 6.jpg +3 -0
- sample_images/factory_area.jpg +3 -0
- sample_images/factory_area.jpg:Zone.Identifier +0 -0
- tests/conftest.py +68 -0
- tests/test_e2e_forms.py +307 -0
- tests/test_e2e_samples.py +148 -0
- tests/test_e2e_workflow.py +150 -0
- tests/test_samples.py +296 -0
- tests/test_tabs.py +37 -17
- ui/constants.py +127 -0
- ui/samples.py +357 -0
- ui/state.py +18 -3
- ui/storage.py +10 -3
- ui/tabs/images.py +81 -42
- ui/tabs/observations.py +1 -1
- ui/tabs/project.py +54 -18
- ui/tabs/rooms.py +53 -18
.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
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
with gr.Tabs() as tabs:
|
| 51 |
# Tab 1: Project Information
|
| 52 |
-
|
|
|
|
| 53 |
tab1 = project.create_tab()
|
| 54 |
|
| 55 |
# Tab 2: Building/Rooms
|
| 56 |
-
|
|
|
|
| 57 |
tab2 = rooms.create_tab()
|
| 58 |
|
| 59 |
# Tab 3: Images
|
| 60 |
-
|
|
|
|
| 61 |
tab3 = images.create_tab()
|
| 62 |
|
| 63 |
# Tab 4: Observations
|
| 64 |
-
|
|
|
|
| 65 |
tab4 = observations.create_tab()
|
| 66 |
|
| 67 |
# Tab 5: Generate Results
|
| 68 |
-
|
|
|
|
| 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["
|
|
|
|
| 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["
|
|
|
|
| 121 |
],
|
| 122 |
)
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
tab2["clear_form_btn"].click(
|
| 125 |
-
fn=lambda: ("",
|
| 126 |
outputs=[
|
| 127 |
tab2["room_name"],
|
| 128 |
tab2["room_floor"],
|
| 129 |
tab2["room_length"],
|
| 130 |
tab2["room_width"],
|
| 131 |
-
tab2["
|
|
|
|
| 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 |
-
# ---
|
| 329 |
-
#
|
|
|
|
| 330 |
|
| 331 |
-
# Tab 1 (Project): Load project form fields
|
| 332 |
-
|
| 333 |
-
fn=
|
| 334 |
-
|
| 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 |
-
|
| 356 |
-
fn=
|
| 357 |
-
|
| 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
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 385 |
-
fn=
|
| 386 |
-
|
| 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 |
-
|
| 415 |
-
|
| 416 |
-
|
|
|
|
| 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
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
from models.real import RealModelStack
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
def __init__(self, dimension: int =
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
def rerank(self, query: str, documents: list[str]) -> list[float]:
|
| 123 |
-
"""Return mock reranking scores.
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
scores = []
|
| 126 |
query_words = set(query.lower().split())
|
|
|
|
| 127 |
for doc in documents:
|
| 128 |
doc_words = set(doc.lower().split())
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
self.loaded = True
|
| 152 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
# Vision model (~58GB in BF16)
|
| 34 |
-
|
|
|
|
| 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 |
-
|
| 50 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
with torch.no_grad():
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
#
|
| 330 |
-
embeddings = outputs.last_hidden_state
|
| 331 |
|
| 332 |
-
#
|
| 333 |
-
embeddings = torch.nn.functional.normalize(embeddings, p=2, dim
|
| 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
|
| 401 |
-
#
|
| 402 |
-
|
| 403 |
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 23 |
"""
|
| 24 |
|
| 25 |
-
EMBEDDING_DIM =
|
| 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 |
-
|
| 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 #
|
| 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 |
-
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
sample_images/Bar and dining area2.jpg
ADDED
|
Git LFS Details
|
sample_images/Bar and dining area3.jpg
ADDED
|
Git LFS Details
|
sample_images/Bar area1.jpg
ADDED
|
Git LFS Details
|
sample_images/Bar area2.jpg
ADDED
|
Git LFS Details
|
sample_images/Bar area3.jpg
ADDED
|
Git LFS Details
|
sample_images/Kitchen 1.jpg
ADDED
|
Git LFS Details
|
sample_images/Kitchen 2.jpg
ADDED
|
Git LFS Details
|
sample_images/Kitchen 3.jpg
ADDED
|
Git LFS Details
|
sample_images/Kitchen 4.jpg
ADDED
|
Git LFS Details
|
sample_images/Kitchen 5.jpg
ADDED
|
Git LFS Details
|
sample_images/Kitchen 6.jpg
ADDED
|
Git LFS Details
|
sample_images/factory_area.jpg
ADDED
|
Git LFS Details
|
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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 32 |
-
label="Upload
|
| 33 |
-
|
| 34 |
-
|
| 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
|
| 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 |
-
|
| 114 |
room_id: str,
|
| 115 |
description: str,
|
| 116 |
-
) -> tuple[SessionState, list[tuple], str, str, None,
|
| 117 |
-
"""Add
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
Returns:
|
| 120 |
Tuple of (session, gallery_data, validation_html, image_count,
|
| 121 |
-
|
| 122 |
"""
|
| 123 |
validation_html = ""
|
| 124 |
|
| 125 |
# Validate input
|
| 126 |
errors = []
|
| 127 |
-
if
|
| 128 |
-
errors.append("Please upload
|
| 129 |
if not room_id:
|
| 130 |
-
errors.append("Please select a room for
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)} / {
|
| 145 |
-
return session, gallery_data, validation_html, count_str,
|
| 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
|
| 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 |
-
#
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
session.update_timestamp()
|
| 171 |
|
| 172 |
# Success message
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
<
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
gallery_data = _get_gallery_data(session)
|
| 180 |
-
count_str = f"{len(session.images)} / {
|
| 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.
|
| 51 |
label="State *",
|
| 52 |
-
|
| 53 |
elem_id="state",
|
|
|
|
| 54 |
)
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 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.
|
| 67 |
label="Fire Date *",
|
| 68 |
-
|
|
|
|
| 69 |
elem_id="fire_date",
|
| 70 |
)
|
| 71 |
-
assessment_date = gr.
|
| 72 |
label="Assessment Date *",
|
| 73 |
-
|
|
|
|
| 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.
|
| 99 |
label="Credentials (optional)",
|
| 100 |
-
|
|
|
|
| 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.
|
| 31 |
label="Floor (optional)",
|
| 32 |
-
|
| 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 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
minimum=1,
|
| 51 |
value=None,
|
| 52 |
-
|
|
|
|
| 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 |
-
"
|
|
|
|
| 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 |
-
|
| 129 |
-
|
|
|
|
| 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,
|
|
|
|
| 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
|
| 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
|
| 170 |
length,
|
| 171 |
width,
|
| 172 |
-
|
|
|
|
| 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 |
-
|
| 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]:
|