Spaces:
Paused
Paused
| """Chat handler for Q&A and document modifications. | |
| Uses the vision model (Qwen3-VL-30B-A3B-Thinking-FP8) in text-only mode | |
| for chat interactions. The model handles both Q&A about assessment results | |
| and document modification requests. | |
| """ | |
| import json | |
| import logging | |
| import re | |
| from typing import Optional | |
| from config.settings import settings | |
| logger = logging.getLogger(__name__) | |
| # Chat system prompt | |
| CHAT_SYSTEM_PROMPT = """You are an expert industrial hygienist assistant helping with fire damage assessment. You have access to the assessment results and can answer questions or modify the generated document. | |
| ## Your Capabilities | |
| 1. **Answer Questions**: Explain zone classifications, material detections, sampling recommendations, and methodology. | |
| 2. **Document Modifications**: Add notes, update sections, or make changes to the generated Scope of Work document. | |
| ## When Modifying Documents | |
| If the user requests a document change, include a modification command in your response using this format: | |
| <document_edit> | |
| { | |
| "action": "add_note" | "add_section" | "append_to_section", | |
| "section": "Section Name", | |
| "content": "Content to add..." | |
| } | |
| </document_edit> | |
| Available sections: | |
| - Additional Notes (for general notes) | |
| - Room Information | |
| - Scope Summary | |
| - AI Vision Analysis Summary | |
| - Field Observations | |
| - Material Dispositions | |
| - Cleaning Specifications | |
| - Air Filtration Requirements | |
| - Sampling Plan | |
| - Regulatory References | |
| - Threshold Documentation | |
| - Disclaimer | |
| ## Guidelines | |
| - Be concise and professional | |
| - Reference specific assessment data when answering questions | |
| - For modifications, only change what the user requests | |
| - Always explain changes you're making""" | |
| class ChatHandler: | |
| """Handles chat interactions for assessment Q&A and document modifications.""" | |
| def __init__(self, model_stack=None): | |
| """Initialize chat handler. | |
| Args: | |
| model_stack: Model stack (RealModelStack or MockModelStack). | |
| If None, will be loaded from get_models(). | |
| """ | |
| self.model_stack = model_stack | |
| def process_message( | |
| self, | |
| user_message: str, | |
| session_state, | |
| chat_history: list[dict], | |
| ) -> tuple[str, Optional[dict], list[dict]]: | |
| """Process a chat message and return response. | |
| Args: | |
| user_message: The user's message | |
| session_state: Current SessionState with assessment data | |
| chat_history: Previous chat messages in Gradio messages format | |
| Returns: | |
| Tuple of (response_text, document_edit_or_none, updated_chat_history) | |
| """ | |
| # Lazy load model stack if not provided | |
| if self.model_stack is None: | |
| from models.loader import get_models | |
| self.model_stack = get_models() | |
| # Build context from session | |
| context = self._build_context(session_state) | |
| # Generate response | |
| if settings.mock_models: | |
| response = self._generate_mock_response(user_message, context) | |
| else: | |
| response = self._generate_real_response( | |
| user_message, context, chat_history | |
| ) | |
| # Parse for document edits | |
| document_edit = self._parse_document_edit(response) | |
| # Clean response (remove document_edit tags for display) | |
| display_response = re.sub( | |
| r'<document_edit>.*?</document_edit>', | |
| '', | |
| response, | |
| flags=re.DOTALL | |
| ).strip() | |
| # Update chat history | |
| updated_history = chat_history.copy() | |
| updated_history.append({"role": "user", "content": user_message}) | |
| updated_history.append({"role": "assistant", "content": display_response}) | |
| return display_response, document_edit, updated_history | |
| def _build_context(self, session_state) -> str: | |
| """Build context string from session state for chat.""" | |
| context_parts = [] | |
| # Room info | |
| room = session_state.room | |
| context_parts.append(f"Room: {room.name}") | |
| context_parts.append(f"Dimensions: {room.length_ft}' x {room.width_ft}' x {room.ceiling_height_ft}'") | |
| context_parts.append(f"Facility: {room.facility_classification}") | |
| context_parts.append(f"Era: {room.construction_era}") | |
| # Images summary | |
| context_parts.append(f"Images analyzed: {len(session_state.images)}") | |
| # Pipeline results if available | |
| if session_state.pipeline_result_json: | |
| try: | |
| result = json.loads(session_state.pipeline_result_json) | |
| if result.get("vision_results"): | |
| context_parts.append("\nVision Analysis Summary:") | |
| for img_id, analysis in result["vision_results"].items(): | |
| zone = analysis.get("zone", {}).get("classification", "unknown") | |
| condition = analysis.get("condition", {}).get("level", "unknown") | |
| context_parts.append(f" - {img_id}: zone={zone}, condition={condition}") | |
| if result.get("dispositions"): | |
| context_parts.append(f"\nDispositions: {len(result['dispositions'])} materials analyzed") | |
| except json.JSONDecodeError: | |
| pass | |
| return "\n".join(context_parts) | |
| def _generate_real_response( | |
| self, | |
| user_message: str, | |
| context: str, | |
| chat_history: list[dict], | |
| ) -> str: | |
| """Generate response using real vision model in text-only mode.""" | |
| try: | |
| # Get vision model components | |
| vision = self.model_stack.vision | |
| model = vision.model | |
| processor = vision.processor | |
| sampling_params = vision.sampling_params | |
| # Build messages (text-only, no image) | |
| messages = [ | |
| {"role": "system", "content": CHAT_SYSTEM_PROMPT}, | |
| ] | |
| # Add context as first user message | |
| messages.append({ | |
| "role": "user", | |
| "content": f"Assessment Context:\n{context}" | |
| }) | |
| messages.append({ | |
| "role": "assistant", | |
| "content": "I understand the assessment context. How can I help you?" | |
| }) | |
| # Add chat history | |
| for msg in chat_history: | |
| messages.append(msg) | |
| # Add current user message | |
| messages.append({"role": "user", "content": user_message}) | |
| # Apply chat template | |
| prompt = processor.apply_chat_template( | |
| messages, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| ) | |
| # Generate (text-only: no multi_modal_data) | |
| outputs = model.generate( | |
| prompts=[prompt], # Just prompt string, no image | |
| sampling_params=sampling_params, | |
| ) | |
| return outputs[0].outputs[0].text | |
| except Exception as e: | |
| logger.error(f"Chat generation failed: {e}") | |
| return f"I apologize, but I encountered an error processing your request: {str(e)}" | |
| def _generate_mock_response(self, user_message: str, context: str) -> str: | |
| """Generate mock response for local development.""" | |
| user_lower = user_message.lower() | |
| # Pattern matching for common questions | |
| if "zone" in user_lower or "classification" in user_lower: | |
| return """Zone classifications per FDAM §4.1 (IICRC/RIA/CIRI Technical Guide): | |
| - **Burn Zone**: Direct fire involvement with structural char and complete combustion | |
| - **Near-Field**: Adjacent to burn zone, heavy smoke/heat exposure, visible contamination | |
| - **Far-Field**: Smoke migration only, light deposits, no structural damage | |
| The AI analyzed each image to determine which zone best describes the visible conditions based on these criteria.""" | |
| if "material" in user_lower: | |
| return """Materials are categorized by porosity, which affects cleaning requirements: | |
| - **Non-porous**: Steel, concrete, glass, CMU - can typically be cleaned | |
| - **Semi-porous**: Painted drywall, sealed wood - may require evaluation | |
| - **Porous**: Carpet, insulation, acoustic tile - often require removal | |
| The assessment identified materials visible in each image and assigned dispositions based on contamination level.""" | |
| if "sampling" in user_lower or "sample" in user_lower: | |
| return """The sampling plan follows FDAM §2.3 requirements: | |
| - **Tape lifts**: For particle identification via PLM (polarized light microscopy) | |
| - **Surface wipes**: For metals quantification per NIOSH Method 9100 / BNL SOP IH75190 | |
| **Sample Density per FDAM §2.3:** | |
| - <5,000 SF: 3-5 samples per surface type | |
| - 5,000-25,000 SF: 5-10 samples per surface type | |
| - 25,000-100,000 SF: 10-20 samples per surface type | |
| - >100,000 SF: 20+ samples per surface type | |
| **Ceiling Deck Enhancement (FDAM §4.5):** 1 sample per 2,500 SF due to 82.4% pass rate vs 95%+ for other surfaces.""" | |
| if "add" in user_lower and "note" in user_lower: | |
| # Extract what they want to add | |
| return """I'll add that note to the document. | |
| <document_edit> | |
| { | |
| "action": "add_note", | |
| "section": "Additional Notes", | |
| "content": "Note added per assessor request during review." | |
| } | |
| </document_edit> | |
| The note has been added to the Additional Notes section.""" | |
| if "explain" in user_lower or "why" in user_lower: | |
| return """Based on the visual analysis and FDAM methodology: | |
| The zone and condition classifications are determined by analyzing: | |
| 1. Distance from fire origin indicators | |
| 2. Visible contamination patterns | |
| 3. Structural damage presence | |
| 4. Material surface conditions | |
| Each factor contributes to the overall assessment with confidence scores reflecting certainty.""" | |
| # Default response | |
| return """I can help you understand the assessment results or make changes to the document. | |
| **Questions I can answer:** | |
| - Zone classification explanations | |
| - Material detection details | |
| - Sampling plan rationale | |
| - Methodology references | |
| **Changes I can make:** | |
| - Add notes to any section | |
| - Update specific content | |
| - Add clarifications | |
| What would you like to know or change?""" | |
| def _parse_document_edit(self, response: str) -> Optional[dict]: | |
| """Parse document edit command from response.""" | |
| match = re.search( | |
| r'<document_edit>\s*(\{.*?\})\s*</document_edit>', | |
| response, | |
| re.DOTALL | |
| ) | |
| if match: | |
| try: | |
| return json.loads(match.group(1)) | |
| except json.JSONDecodeError: | |
| logger.warning("Failed to parse document edit JSON") | |
| return None | |
| return None | |
| def apply_document_edit( | |
| self, | |
| document: str, | |
| edit: dict, | |
| ) -> str: | |
| """Apply a document edit to the current document. | |
| Args: | |
| document: Current markdown document | |
| edit: Edit command with action, section, content | |
| Returns: | |
| Modified document | |
| """ | |
| action = edit.get("action", "") | |
| section = edit.get("section", "") | |
| content = edit.get("content", "") | |
| if not content: | |
| return document | |
| if action == "add_note": | |
| # Add note to Additional Notes section, or create it | |
| if "## Additional Notes" in document: | |
| # Append to existing section | |
| document = document.replace( | |
| "## Additional Notes", | |
| f"## Additional Notes\n\n{content}" | |
| ) | |
| else: | |
| # Add before Disclaimer | |
| if "## Disclaimer" in document: | |
| document = document.replace( | |
| "## Disclaimer", | |
| f"## Additional Notes\n\n{content}\n\n---\n\n## Disclaimer" | |
| ) | |
| else: | |
| # Add at end | |
| document += f"\n\n---\n\n## Additional Notes\n\n{content}" | |
| elif action == "add_section": | |
| # Add new section before Disclaimer | |
| new_section = f"## {section}\n\n{content}" | |
| if "## Disclaimer" in document: | |
| document = document.replace( | |
| "## Disclaimer", | |
| f"{new_section}\n\n---\n\n## Disclaimer" | |
| ) | |
| else: | |
| document += f"\n\n---\n\n{new_section}" | |
| elif action == "append_to_section": | |
| # Find section and append content | |
| section_pattern = rf"(## {re.escape(section)}.*?)(\n---|\Z)" | |
| match = re.search(section_pattern, document, re.DOTALL) | |
| if match: | |
| section_content = match.group(1) | |
| document = document.replace( | |
| section_content, | |
| f"{section_content}\n\n{content}" | |
| ) | |
| return document | |
| # Quick action messages | |
| QUICK_ACTIONS = { | |
| "explain_zones": "Explain how the zone classifications were determined for this assessment.", | |
| "explain_materials": "What materials were detected and why were they categorized this way?", | |
| "explain_sampling": "Explain the sampling plan recommendations.", | |
| "add_note": "I need to add a note to the document. Please tell me what note you'd like to add.", | |
| } | |
| def get_quick_action_message(action_key: str) -> str: | |
| """Get the message for a quick action button.""" | |
| return QUICK_ACTIONS.get(action_key, "") | |