"""Streamlit UI for NYC Code Compliance Bot โ with agent discussion panel.""" from __future__ import annotations import base64 import hashlib import hmac import json import logging import os import tempfile import time from pathlib import Path import pandas as pd import requests import streamlit as st from dotenv import load_dotenv from huggingface_hub import HfApi, hf_hub_download from huggingface_hub.utils import EntryNotFoundError from PIL import Image from config import MAX_INVESTIGATION_ROUNDS from graph import compile_compliance_graph from tools.chroma_tools import warmup_collection, is_warmed_up from tools.crop_cache import CropCache from tools.image_store import ImageStore from tools.metadata_cache import MetadataState, get_cached_metadata from tools.pdf_processor import render_pages load_dotenv() logger = logging.getLogger(__name__) # ============================================================================= # HF OAuth + Usage Quota Configuration # ============================================================================= USAGE_DATASET_REPO = "NYSERDA-CRE-Working-Group/nyserda_demo_useage_store" USAGE_FILENAME = "usage.csv" MAX_RUNS_PER_USER = 10 HF_TOKEN = os.environ.get("HF_TOKEN") OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID") OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET") OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co") SPACE_HOST = os.environ.get("SPACE_HOST", "") _hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None # --------------------------------------------------------------------------- # OAuth helpers # --------------------------------------------------------------------------- def _get_oauth_state() -> str: """Generate a deterministic OAuth state string for CSRF protection.""" secret = (OAUTH_CLIENT_SECRET or "fallback-secret").encode() return hmac.new(secret, b"hf-oauth-state", hashlib.sha256).hexdigest()[:32] def _get_redirect_uri() -> str: """Build the OAuth redirect URI pointing back to this Space.""" if SPACE_HOST: return f"https://{SPACE_HOST}" return "http://localhost:7860" def _exchange_code_for_user(code: str) -> str | None: """Exchange the OAuth authorization code for an access token, then fetch username.""" if not OAUTH_CLIENT_ID or not OAUTH_CLIENT_SECRET: st.error("OAuth is not configured. Set hf_oauth: true in your Space's README.md") return None redirect_uri = _get_redirect_uri() token_url = f"{OPENID_PROVIDER_URL}/oauth/token" credentials = base64.b64encode( f"{OAUTH_CLIENT_ID}:{OAUTH_CLIENT_SECRET}".encode() ).decode() try: resp = requests.post( token_url, headers={"Authorization": f"Basic {credentials}"}, data={ "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "client_id": OAUTH_CLIENT_ID, }, timeout=10, ) resp.raise_for_status() token_data = resp.json() except Exception as e: st.error(f"Token exchange failed: {e}") return None access_token = token_data.get("access_token") if not access_token: st.error("No access token in OAuth response.") return None try: userinfo_resp = requests.get( f"{OPENID_PROVIDER_URL}/oauth/userinfo", headers={"Authorization": f"Bearer {access_token}"}, timeout=10, ) userinfo_resp.raise_for_status() userinfo = userinfo_resp.json() except Exception as e: st.error(f"Failed to fetch user info: {e}") return None username = userinfo.get("preferred_username") or userinfo.get("sub") if username: return username.strip().lower() return None def get_hf_user() -> str | None: """Check if user is logged in via HF OAuth (authorization code flow).""" if "hf_user" in st.session_state: return st.session_state["hf_user"] params = st.query_params code = params.get("code") returned_state = params.get("state") if code: expected_state = _get_oauth_state() if returned_state == expected_state: user = _exchange_code_for_user(code) if user: st.session_state["hf_user"] = user st.query_params.clear() st.rerun() else: st.error("OAuth login failed โ could not retrieve user info.") st.query_params.clear() else: st.error("OAuth state mismatch โ please try signing in again.") st.query_params.clear() return None def login_button(): """Show a 'Sign in with Hugging Face' button that starts the OAuth flow.""" if not OAUTH_CLIENT_ID: st.error( "OAuth is not configured for this Space. " "Add `hf_oauth: true` to your README.md metadata." ) return if st.button("๐ค Sign in with Hugging Face", use_container_width=True, type="primary"): state = _get_oauth_state() redirect_uri = _get_redirect_uri() auth_url = ( f"{OPENID_PROVIDER_URL}/oauth/authorize" f"?client_id={OAUTH_CLIENT_ID}" f"&redirect_uri={redirect_uri}" f"&scope=openid%20profile" f"&response_type=code" f"&state={state}" ) st.markdown( f'', unsafe_allow_html=True, ) st.stop() # --------------------------------------------------------------------------- # Usage quota helpers # --------------------------------------------------------------------------- def _load_usage_df() -> pd.DataFrame: """Load the usage CSV from HF dataset repo.""" if not _hf_api or not USAGE_DATASET_REPO: return pd.DataFrame(columns=["user_id", "runs", "first_seen", "last_seen"]) try: local_path = hf_hub_download( repo_id=USAGE_DATASET_REPO, repo_type="dataset", filename=USAGE_FILENAME, token=HF_TOKEN, ) return pd.read_csv(local_path) except EntryNotFoundError: return pd.DataFrame(columns=["user_id", "runs", "first_seen", "last_seen"]) def _save_usage_df(df: pd.DataFrame, commit_message: str) -> None: """Save the usage CSV back to HF dataset repo.""" if not _hf_api or not USAGE_DATASET_REPO: return tmp_path = "/tmp/usage.csv" df.to_csv(tmp_path, index=False) _hf_api.upload_file( path_or_fileobj=tmp_path, path_in_repo=USAGE_FILENAME, repo_id=USAGE_DATASET_REPO, repo_type="dataset", commit_message=commit_message, ) def check_and_increment_quota(user_id: str) -> tuple[bool, int]: """Check if user has remaining quota and increment usage. Returns (allowed: bool, remaining_runs: int). If USAGE_DATASET_REPO is not configured, always allows (unlimited). """ if not USAGE_DATASET_REPO: return True, 999 now = int(time.time()) df = _load_usage_df() if df.empty or (df["user_id"] == user_id).sum() == 0: runs = 0 if runs >= MAX_RUNS_PER_USER: return False, 0 new_row = { "user_id": user_id, "runs": 1, "first_seen": now, "last_seen": now, } df = pd.concat([df, pd.DataFrame([new_row])], ignore_index=True) _save_usage_df(df, commit_message=f"usage: increment {user_id} to 1") return True, MAX_RUNS_PER_USER - 1 idx = df.index[df["user_id"] == user_id][0] runs = int(df.loc[idx, "runs"]) if runs >= MAX_RUNS_PER_USER: return False, 0 runs += 1 df.loc[idx, "runs"] = runs df.loc[idx, "last_seen"] = now _save_usage_df(df, commit_message=f"usage: increment {user_id} to {runs}") return True, MAX_RUNS_PER_USER - runs # --------------------------------------------------------------------------- # Page config # --------------------------------------------------------------------------- st.set_page_config( page_title="NYC Code Compliance Bot", page_icon=":building_construction:", layout="wide", ) # --------------------------------------------------------------------------- # Authentication gate โ must sign in before using the app # --------------------------------------------------------------------------- uid = get_hf_user() if not uid: _left, center, _right = st.columns([1, 2, 1]) with center: st.markdown( "
" "Sign in with your Hugging Face account to get started." "
", unsafe_allow_html=True, ) login_button() st.stop() # --------------------------------------------------------------------------- # Custom CSS for agent discussion panel # --------------------------------------------------------------------------- st.markdown(""" """, unsafe_allow_html=True) AGENT_ICONS = { "planner": "\U0001f4cb", "code_analyst": "\u2696\ufe0f", "compliance_analyst": "\U0001f50d", "reviewer": "\U0001f91d", } AGENT_LABELS = { "planner": "Planner", "code_analyst": "Code Analyst", "compliance_analyst": "Compliance Analyst", "reviewer": "Reviewer", } # --------------------------------------------------------------------------- # Session state defaults # --------------------------------------------------------------------------- if "pdf_loaded" not in st.session_state: st.session_state.pdf_loaded = False if "chat_history" not in st.session_state: st.session_state.chat_history = [] if "image_store" not in st.session_state: st.session_state.image_store = None if "ingest_state" not in st.session_state: st.session_state.ingest_state = {} if "pdf_bytes" not in st.session_state: st.session_state.pdf_bytes = None if "metadata_state" not in st.session_state: st.session_state.metadata_state = MetadataState() if "crop_cache" not in st.session_state: st.session_state.crop_cache = CropCache() if "discussion_log" not in st.session_state: st.session_state.discussion_log = [] if "code_report" not in st.session_state: st.session_state.code_report = "" if "code_sections" not in st.session_state: st.session_state.code_sections = [] if "image_refs" not in st.session_state: st.session_state.image_refs = [] if "db_ready" not in st.session_state: st.session_state.db_ready = False # --------------------------------------------------------------------------- # Startup: warm up embedding model + ChromaDB # --------------------------------------------------------------------------- if not st.session_state.db_ready: with st.status("Loading NYC Code Database...", expanded=True) as _db_status: st.write(":brain: Loading embedding model (bge-large-en-v1.5)...") st.write("_This is a one-time download (~1.3 GB) on first run._") ok = warmup_collection() if ok: st.session_state.db_ready = True _db_status.update(label="NYC Code Database ready", state="complete") else: _db_status.update( label="NYC Code Database not available โ code lookup will be disabled", state="error", ) st.session_state.db_ready = False if st.session_state.db_ready: st.rerun() # --------------------------------------------------------------------------- # Sidebar # --------------------------------------------------------------------------- with st.sidebar: st.title(":building_construction: NYC Code Compliance Bot") st.markdown( "Upload a construction drawing PDF and ask compliance questions. " "The system uses **agentic vision** + **NYC code database** to " "verify code compliance." ) st.divider() st.markdown( "During business hours Gemini servers see high traffic. " "This may slow or crash the application, this is a Gemini issue," "if a response takes more than 1 minute try again later." ) # PDF upload uploaded_file = st.file_uploader("Upload Drawing PDF", type=["pdf"]) # Default drawing button _DEFAULT_PDF = Path(__file__).parent / "NorthMaconPark.pdf" if _DEFAULT_PDF.exists() and not st.session_state.pdf_loaded: st.markdown("**โ or โ**") if st.button("Use Default Drawing", use_container_width=True): st.session_state._use_default_pdf = True st.rerun() st.divider() # Settings st.subheader("Settings") enable_consensus = st.checkbox( "Enable peer review (Gemini + GPT)", value=False, help="GPT reviews Gemini's compliance analysis. Slower but more thorough.", ) enable_annotation = st.checkbox( "Enable annotation", value=False, help="Annotate crops with numbered highlights before analysis.", ) max_rounds = st.slider( "Max investigation rounds", min_value=1, max_value=5, value=MAX_INVESTIGATION_ROUNDS, help="Maximum crop-analyze loops before forcing a final verdict.", ) st.divider() # --- User info + sign out --- st.caption(f"Signed in as: **{uid}**") if st.button("Sign out", use_container_width=True): st.session_state.pop("hf_user", None) st.rerun() st.divider() st.caption("Powered by LangGraph + Gemini + GPT + ChromaDB") # --------------------------------------------------------------------------- # PDF ingestion โ Phase A: render pages # --------------------------------------------------------------------------- _pending_pdf: tuple[str, bytes] | None = None if not st.session_state.pdf_loaded: if uploaded_file is not None: _pending_pdf = (uploaded_file.name, uploaded_file.getvalue()) elif st.session_state.get("_use_default_pdf"): _default = Path(__file__).parent / "NorthMaconPark.pdf" if _default.exists(): _pending_pdf = (_default.name, _default.read_bytes()) if _pending_pdf is not None and not st.session_state.pdf_loaded: pdf_name, pdf_bytes = _pending_pdf with st.status("Converting PDF to images...", expanded=True) as status: tmp_dir = tempfile.mkdtemp(prefix="compliance_bot_") pdf_path = Path(tmp_dir) / pdf_name pdf_path.write_bytes(pdf_bytes) st.session_state.pdf_bytes = pdf_bytes st.session_state.crop_cache = CropCache() image_store = ImageStore(str(Path(tmp_dir) / "images")) st.session_state.image_store = image_store page_image_dir = str(image_store._pages_dir) cached = get_cached_metadata(pdf_bytes) if cached is not None: st.session_state.metadata_state.set_ready(json.dumps(cached, indent=2)) st.write("Page index loaded from cache") st.write("Rendering pages...") num_pages = render_pages(str(pdf_path), page_image_dir) st.session_state.ingest_state = { "pdf_path": str(pdf_path), "page_image_dir": page_image_dir, "num_pages": num_pages, } st.session_state.pdf_loaded = True st.session_state.pop("_use_default_pdf", None) st.write(f"Converted {num_pages} pages to images.") status.update(label=f"PDF ready: {num_pages} pages", state="complete") st.rerun() # --------------------------------------------------------------------------- # PDF ingestion โ Phase B: generate page index # --------------------------------------------------------------------------- if st.session_state.pdf_loaded: meta = st.session_state.metadata_state if meta.status == "not_started": if st.session_state.pdf_bytes is not None: with st.expander(":page_facing_up: PDF Viewer", expanded=False): st.pdf(st.session_state.pdf_bytes, height=400) ingest = st.session_state.ingest_state num_pages = ingest["num_pages"] st.write("**Generating page index...**") progress_bar = st.progress(0, text="Analyzing pages to build searchable index...") def _index_progress(completed: int, total: int, label: str): pct = completed / total progress_bar.progress(pct, text=f"Indexing: {label} ({completed}/{total} batches)") meta.generate_sync( ingest["pdf_path"], num_pages, st.session_state.pdf_bytes, progress_callback=_index_progress, ) if meta.is_ready: progress_bar.progress(1.0, text="Page index ready!") else: progress_bar.progress(1.0, text="Indexing failed โ using full PDF mode") st.rerun() # --------------------------------------------------------------------------- # Main layout (pre-upload welcome) # --------------------------------------------------------------------------- if not st.session_state.pdf_loaded: _left, center, _right = st.columns([1, 2, 1]) with center: st.markdown( ""
"Upload a construction drawing PDF in the sidebar to get started.
"
"This tool uses agentic vision and the NYC Building Code database "
"to verify code compliance in your drawings."
"