"""Tab 2: Results + Chat.
Display generated assessment results with integrated chat for Q&A and modifications.
"""
import json
import gradio as gr
from typing import Any, Optional, TYPE_CHECKING
from datetime import datetime
import tempfile
from ui.state import SessionState
from ui.components import create_stats_dict, create_progress_html, image_store
# Lazy imports to avoid chromadb dependency at module load time
# These are imported when generate_assessment() is called
if TYPE_CHECKING:
from pipeline import FDAMPipeline, PipelineResult, PDFGenerator
def create_tab() -> dict[str, Any]:
"""Create Results + Chat tab UI components.
Returns:
Dictionary of component references for event wiring.
"""
# --- Processing Section ---
with gr.Row():
generate_btn = gr.Button(
"Generate Assessment",
variant="primary",
scale=2,
elem_id="generate_btn",
)
processing_status = gr.Textbox(
label="Status",
value="Ready",
interactive=False,
elem_id="processing_status",
)
progress_html = gr.HTML(
value="",
elem_id="progress_html",
)
# --- Results Display ---
gr.Markdown("---")
with gr.Row():
with gr.Column(scale=2):
gr.Markdown("#### Annotated Images")
annotated_gallery = gr.Gallery(
label="AI-Analyzed Images",
columns=2,
height="auto",
elem_id="annotated_gallery",
)
with gr.Column(scale=1):
gr.Markdown("#### Assessment Summary")
stats_output = gr.JSON(
label="Statistics",
elem_id="stats_output",
)
gr.Markdown("---")
gr.Markdown("### Cleaning Specification / Scope of Work")
sow_output = gr.Markdown(
value="*Generate an assessment to see results here.*",
elem_id="sow_output",
)
# --- Downloads ---
gr.Markdown("#### Downloads")
with gr.Row():
download_md = gr.File(
label="Download Markdown (.md)",
elem_id="download_md",
)
download_pdf = gr.File(
label="Download PDF (.pdf)",
elem_id="download_pdf",
)
# --- Chat Interface ---
gr.Markdown("---")
gr.Markdown("### Ask Questions or Request Changes")
gr.Markdown(
"*Chat with the AI about the assessment results or request document modifications.*"
)
chatbot = gr.Chatbot(
label="Chat",
# type parameter removed in Gradio 6.x - messages format is default
height=300,
elem_id="chatbot",
)
with gr.Row():
chat_input = gr.Textbox(
label="Message",
placeholder="Ask a question or request a change...",
scale=4,
elem_id="chat_input",
)
chat_send_btn = gr.Button("Send", variant="primary", scale=1)
# Quick action buttons
with gr.Row():
gr.Markdown("**Quick Actions:**")
with gr.Row():
quick_explain_zones = gr.Button("Explain zone classifications", size="sm")
quick_explain_materials = gr.Button("Explain detected materials", size="sm")
quick_sampling = gr.Button("Explain sampling plan", size="sm")
quick_add_note = gr.Button("Add a note to document", size="sm")
# Navigation
with gr.Row():
back_btn = gr.Button("← Back to Input")
regenerate_btn = gr.Button(
"Regenerate Assessment",
variant="secondary",
)
reset_doc_btn = gr.Button(
"Reset Document",
variant="secondary",
)
return {
# Generation controls
"generate_btn": generate_btn,
"processing_status": processing_status,
"progress_html": progress_html,
# Results display
"annotated_gallery": annotated_gallery,
"stats_output": stats_output,
"sow_output": sow_output,
# Downloads
"download_md": download_md,
"download_pdf": download_pdf,
# Chat interface
"chatbot": chatbot,
"chat_input": chat_input,
"chat_send_btn": chat_send_btn,
# Quick actions
"quick_explain_zones": quick_explain_zones,
"quick_explain_materials": quick_explain_materials,
"quick_sampling": quick_sampling,
"quick_add_note": quick_add_note,
# Navigation
"back_btn": back_btn,
"regenerate_btn": regenerate_btn,
"reset_doc_btn": reset_doc_btn,
}
def check_preflight(session: SessionState) -> str:
"""Check if assessment can be generated.
Returns:
HTML string with preflight status.
"""
can_generate, errors = session.can_generate()
# Also check if images are in memory
expected_ids = [img.id for img in session.images]
missing_ids = image_store.get_missing_ids(expected_ids)
if missing_ids:
errors.append(f"{len(missing_ids)} image(s) need to be re-uploaded")
can_generate = False
if can_generate:
stats = create_stats_dict(session)
return f"""
✓ Ready to Generate
Room: {stats['room_name']}
Images: {stats['images']}
Total Area: {stats['total_floor_area_sf']} SF
"""
else:
error_items = "".join(f"{e}" for e in errors)
return f"""
Cannot Generate - Please Fix:
"""
def generate_assessment(
session: SessionState,
progress: Optional[gr.Progress] = None,
) -> tuple[SessionState, str, str, list[tuple], dict, str, Optional[str], Optional[str], list[dict]]:
"""Generate the assessment using the FDAM pipeline.
Returns:
Tuple of (session, status, progress_html, annotated_images,
stats, sow_markdown, md_file_path, pdf_file_path, chat_history).
"""
# Lazy import to avoid chromadb dependency at module load
from pipeline import FDAMPipeline, PipelineResult, PDFGenerator
# Create pipeline instance
pipeline = FDAMPipeline()
# Define progress callback for Gradio
def progress_callback(prog):
if progress:
progress(prog.percent, desc=prog.message)
# Execute pipeline
result: PipelineResult = pipeline.execute(
session=session,
progress_callback=progress_callback,
)
# Handle errors
if not result.success:
error_msg = "**Error:** Please fix the following before generating:\n\n"
error_msg += "\n".join(f"- {e}" for e in result.errors)
return (
result.session,
"Error: Cannot generate",
"",
[],
{},
error_msg,
None,
None,
[], # Clear chat on error
)
# Generate stats dictionary for UI
stats = pipeline.generate_stats_dict(result)
# Get markdown content
sow_markdown = result.document.markdown if result.document else ""
# Store document in session for chat modifications
session.generated_document = sow_markdown
session.original_document = sow_markdown
# Store serializable subset of PipelineResult for chat context
session.pipeline_result_json = _serialize_pipeline_result(result)
# Clear chat history on new generation
session.chat_history = []
# Save markdown file
md_path = None
pdf_path = None
try:
if sow_markdown:
room_name_safe = session.room.name.replace(' ', '_') if session.room.name else "Room"
with tempfile.NamedTemporaryFile(
mode='w',
suffix='.md',
delete=False,
prefix=f"SOW_{room_name_safe}_",
) as f:
f.write(sow_markdown)
md_path = f.name
# Generate PDF
pdf_generator = PDFGenerator()
pdf_result = pdf_generator.generate_pdf(sow_markdown)
if pdf_result.success:
pdf_path = pdf_result.pdf_path
else:
result.warnings.append(f"PDF generation failed: {pdf_result.error_message}")
except Exception as e:
print(f"Error saving files: {e}")
# Add warnings to status if any
status = "Complete"
if result.warnings:
status = f"Complete ({len(result.warnings)} warnings)"
session.has_results = True
session.results_generated_at = datetime.now().isoformat()
session.update_timestamp()
return (
session,
status,
create_progress_html(6, 6, f"Complete! ({result.execution_time_seconds:.1f}s)"),
result.annotated_images,
stats,
sow_markdown,
md_path,
pdf_path,
[], # Reset chat history
)
def _serialize_pipeline_result(result: "PipelineResult") -> str:
"""Serialize PipelineResult to JSON, excluding non-serializable fields.
Excludes:
- annotated_images (contains PIL.Image objects)
- session (complex SessionState object)
- document (GeneratedDocument object)
"""
# Convert VisionResult dataclasses to dicts
vision_results_dict = {}
for img_id, vr in result.vision_results.items():
vision_results_dict[img_id] = {
"zone": vr.zone,
"condition": vr.condition,
"materials": vr.materials,
"bounding_boxes": vr.bounding_boxes,
}
# Convert SurfaceDisposition dataclasses to dicts
dispositions_list = []
for disp in result.dispositions:
dispositions_list.append({
"room_name": disp.room_name,
"surface_type": disp.surface_type,
"zone": disp.zone,
"condition": disp.condition,
"disposition": disp.disposition,
"cleaning_method": disp.cleaning_method,
"notes": disp.notes,
})
serializable = {
"success": result.success,
"errors": result.errors,
"warnings": result.warnings,
"execution_time_seconds": result.execution_time_seconds,
"vision_results": vision_results_dict,
"dispositions": dispositions_list,
"calculations": result.calculations,
}
return json.dumps(serializable, default=str)
def reset_document(session: SessionState) -> tuple[SessionState, str]:
"""Reset document to original generated version."""
if session.original_document:
session.generated_document = session.original_document
session.update_timestamp()
return session, session.original_document
return session, session.generated_document or ""
def regenerate_downloads(
session: SessionState,
) -> tuple[Optional[str], Optional[str]]:
"""Regenerate download files from current document.
Used after chat modifications to update downloads.
"""
sow_markdown = session.generated_document
if not sow_markdown:
return None, None
md_path = None
pdf_path = None
try:
room_name_safe = session.room.name.replace(' ', '_') if session.room.name else "Room"
with tempfile.NamedTemporaryFile(
mode='w',
suffix='.md',
delete=False,
prefix=f"SOW_{room_name_safe}_",
) as f:
f.write(sow_markdown)
md_path = f.name
# Lazy import PDFGenerator
from pipeline import PDFGenerator
pdf_generator = PDFGenerator()
pdf_result = pdf_generator.generate_pdf(sow_markdown)
if pdf_result.success:
pdf_path = pdf_result.pdf_path
except Exception as e:
print(f"Error regenerating files: {e}")
return md_path, pdf_path