""" PropertyPilot v3 — AI Maintenance Assistant Design : Dark timeline / glass-card UI (property_pilot_app.py) Backend: Real FAISS + BGE embeddings + Qwen2.5 generation (propertypilot-v2) New : Fiverr contractor search link """ import os import re import json import html import time from datetime import datetime, timedelta from threading import Thread import torch import pandas as pd import faiss import gradio as gr import spaces import requests from sentence_transformers import SentenceTransformer from huggingface_hub import hf_hub_download from transformers import (AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer, StoppingCriteria, StoppingCriteriaList, DistilBertModel, DistilBertTokenizerFast) from peft import PeftModel from collections import Counter import torch.nn as nn # ── Configuration ───────────────────────────────────────────────────────────── HF_REPO = "propertypilot/property-pilot-tickets" HF_TOKEN = os.environ.get("HF_TOKEN") or None SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL") SLACK_WEBHOOKS = { "P1": os.environ.get("SLACK_WEBHOOK_P1"), "P2": os.environ.get("SLACK_WEBHOOK_P2"), "P3": os.environ.get("SLACK_WEBHOOK_P3"), "P4": os.environ.get("SLACK_WEBHOOK_P4"), } GEN_MODEL = "Qwen/Qwen2.5-1.5B-Instruct" LORA_REPO = "propertypilot/property-pilot-generator" TRIAGE_REPO = "propertypilot/property-pilot-triage" SLA_MAP = { "P1": "4 hours", "P2": "24 hours", "P3": "3-5 business days", "P4": "7-14 business days", } URGENCY_EMOJI = {"P1": "🔴", "P2": "🟠", "P3": "🔵", "P4": "🟢"} URGENCY_HEX = {"P1": "#ff6b6b", "P2": "#ffc078", "P3": "#7bdc9f", "P4": "#7bb3f0"} # Keys MUST match the dataset's own category strings exactly. They previously # used singular/differently-cased names ("Hvac", "Appliance", "Pest") and had # no "Common Areas" at all, so 4 of the 10 real categories silently fell back # to the generic icon and to a generic Fiverr search. CATEGORY_ICONS = { "Electrical": "⚡", "Plumbing": "💧", "HVAC": "🌡️", "Appliances": "🔌", "Structural": "🏗️", "Pests": "🐛", "Security": "🔒", "Noise": "🔊", "Elevator": "🛗", "Common Areas": "🏢", } TONE_INSTRUCTIONS = { "panicked-caps": ("The tenant is panicking. Be extremely calm, warm, and immediate. " "Confirm help is on the way. Do NOT mirror their panic."), "frustrated-repeat": ("The tenant has reported this before and is frustrated. " "Start by sincerely acknowledging their frustration and apologising. " "Give a SPECIFIC date/time commitment."), "passive-aggressive": ("The tenant is passive-aggressive. Be extra warm and proactive. " "Avoid any defensiveness. Thank them for flagging the issue."), "polite-formal": ("The tenant is polite and formal. Match their register exactly — " "be professional, precise, and respectful."), "vague-confused": ("The tenant is unsure about the problem. Be clear and patient. " "Ask ONE specific clarifying question if the issue is ambiguous."), "multi-issue": ("The tenant raised multiple issues. Address each one briefly. " "Use a numbered list if helpful."), } # ── Fiverr contractor search ────────────────────────────────────────────────── FIVERR_KEYWORDS = { "Electrical": "electrician+wiring+repair", "Plumbing": "plumber+pipe+leak+repair", "HVAC": "hvac+technician+air+conditioning+repair", "Appliances": "appliance+repair+technician", "Structural": "contractor+structural+repair+construction", "Pests": "pest+control+exterminator", "Security": "locksmith+door+lock+repair", "Noise": "noise+mediation+soundproofing", "Elevator": "elevator+lift+technician+repair", "Common Areas": "handyman+home+repair", } # Hours behind each SLA tier, so the UI can show a real clock deadline # instead of the abstract "SLA: 24 hours" -- a dispatcher needs to know WHEN, # not how long. SLA_HOURS = {"P1": 4, "P2": 24, "P3": 96, "P4": 240} _SEVERITY = ["P1", "P2", "P3", "P4"] # index 0 == most severe # Hazards where under-triaging is far costlier than over-triaging. The rule # classifier that generated the training labels has no bare "smoke" trigger # (only `wire...smoke`), so 28 of the 46 smoke-mentioning tickets in the # dataset are labelled P3 -- and the fine-tuned model faithfully learned that. # This net is independent of both models on purpose. _CRITICAL_P1 = re.compile( r"\b(?:smoke|fire|burning\s+smell|smell\s+(?:of\s+)?(?:smoke|gas|burning))\b" r"|\bgas\s*(?:leak|smell)\b|\bcarbon\s+monoxide\b" r"|\belectric(?:al)?\s+shock\b|\bsparking\b|\bflooding\b", re.IGNORECASE, ) # ...but a beeping smoke ALARM is a battery complaint, not a fire. Without # this carve-out the net would escalate every dead-battery ticket to P1. _CRITICAL_EXEMPT = re.compile( r"smoke\s+(?:alarm|detector)s?\s+(?:is\s+|are\s+)?(?:beep|chirp|low battery|need)", re.IGNORECASE, ) def _apply_safety_floor(text, urgency_code): """Returns (code, reason). Never lowers severity -- only raises it.""" if _CRITICAL_P1.search(text or "") and not _CRITICAL_EXEMPT.search(text or ""): if urgency_code != "P1": return "P1", "safety keyword detected in the tenant's message" return urgency_code, None def _merge_urgency(cls_code, retr_code, text): """Safety keyword > agreement > classifier. The two signals are NOT peers, which is why "always take the more severe" was the wrong rule: it escalated on every disagreement and would erode the meaning of P1. The classifier is a *trained urgency predictor* with a measured 0.766 macro-F1 on held-out data. Retrieval's urgency is merely whatever label happened to sit on the nearest neighbour -- similarity search does not predict urgency at all, so it has no measured accuracy on this task. Absent a hazard keyword the classifier therefore decides, and a disagreement is surfaced to the operator rather than silently acted on. Returns (code, note). `note` is None when nothing noteworthy happened. """ # 1. Hazard keywords override BOTH models. This is the one place we know # the classifier's training labels are wrong -- 28 of the 46 tickets # mentioning smoke are labelled P3 in the dataset it learned from. forced, reason = _apply_safety_floor(text, cls_code or retr_code or "P3") if reason: return forced, f"Escalated to P1 — {reason}" # 2. Both signals agree: nothing to arbitrate. if cls_code and retr_code and cls_code == retr_code: return cls_code, None # 3. Disagreement with no hazard signal: the measured predictor wins, but # the operator can see that the two did not line up. if cls_code: note = None if retr_code and retr_code != cls_code: note = (f"Signals disagreed (classifier {cls_code} · " f"similar tickets {retr_code}) — showing the classifier's call") return cls_code, note return retr_code or "P3", None def _deadline_label(code: str) -> str: hours = SLA_HOURS.get(code) if not hours: return f"SLA: {SLA_MAP.get(code, 'TBD')}" due = datetime.now() + timedelta(hours=hours) if hours <= 24: # Same-day / next-day work: the clock time is the useful part. return f"Due {due.strftime('%H:%M')} · in {hours}h" return f"Due {due.strftime('%a %d %b')} · in {hours // 24} days" def fiverr_url(category: str) -> str: kw = FIVERR_KEYWORDS.get(category, "handyman+repair") return f"https://www.fiverr.com/search/gigs?query={kw}" # ── Load data + models at startup ───────────────────────────────────────────── print("Downloading files from HF Hub...") csv_path = hf_hub_download(repo_id=HF_REPO, filename="propertypilot_tickets.csv", repo_type="dataset", token=HF_TOKEN) faiss_path = hf_hub_download(repo_id=HF_REPO, filename="recommender/index.faiss", repo_type="dataset", token=HF_TOKEN) config_path = hf_hub_download(repo_id=HF_REPO, filename="recommender/config.json", repo_type="dataset", token=HF_TOKEN) df = pd.read_csv(csv_path) df.reset_index(drop=True, inplace=True) df["urgency_code"] = df["urgency"].str[:2] print(f"Dataset: {len(df):,} rows") with open(config_path) as f: cfg = json.load(f) MIN_SIM = cfg["min_sim"] index = faiss.read_index(faiss_path) embed_model = SentenceTransformer(cfg["model_name"]) print(f"Embedder: {cfg['model_name']} | MIN_SIM={MIN_SIM} | {index.ntotal:,} vectors") print(f"Loading base model: {GEN_MODEL} + LoRA adapter: {LORA_REPO}...") gen_tokenizer = AutoTokenizer.from_pretrained(LORA_REPO, token=HF_TOKEN) # float16 is the right choice on GPU, but on CPU there is no hardware fp16 # matmul on typical x86 -- torch up-converts every op, which makes generation # markedly SLOWER than plain float32. This Space currently runs on cpu-basic, # so pick per-device instead of hardcoding fp16. _GEN_DTYPE = torch.float16 if torch.cuda.is_available() else torch.float32 gen_model = AutoModelForCausalLM.from_pretrained(GEN_MODEL, torch_dtype=_GEN_DTYPE) gen_model = PeftModel.from_pretrained(gen_model, LORA_REPO, token=HF_TOKEN) gen_model.eval() print("Finetuned generation model ready.") # ── Fine-tuned triage classifier (independent of the retrieval vote above) ──── print("Loading fine-tuned triage classifier...") class TriageClassifier(nn.Module): def __init__(self, n_categories, n_urgencies): super().__init__() self.bert = DistilBertModel.from_pretrained("distilbert-base-uncased") hidden = self.bert.config.hidden_size self.dropout = nn.Dropout(0.2) self.cat_head = nn.Linear(hidden, n_categories) self.urg_head = nn.Linear(hidden, n_urgencies) def forward(self, input_ids, attention_mask): out = self.bert(input_ids=input_ids, attention_mask=attention_mask) cls = self.dropout(out.last_hidden_state[:, 0]) return self.cat_head(cls), self.urg_head(cls) _triage_labels_path = hf_hub_download(TRIAGE_REPO, "label_map.json") _triage_weights_path = hf_hub_download(TRIAGE_REPO, "model.pt") with open(_triage_labels_path) as f: _triage_labels = json.load(f) TRIAGE_CATEGORIES = [_triage_labels["category_id2label"][str(i)] for i in range(len(_triage_labels["category_id2label"]))] TRIAGE_URGENCIES = [_triage_labels["urgency_id2label"][str(i)] for i in range(len(_triage_labels["urgency_id2label"]))] triage_tokenizer = DistilBertTokenizerFast.from_pretrained("distilbert-base-uncased") triage_model = TriageClassifier(len(TRIAGE_CATEGORIES), len(TRIAGE_URGENCIES)) triage_model.load_state_dict(torch.load(_triage_weights_path, map_location="cpu")) triage_model.eval() print(f"Triage classifier loaded: {len(TRIAGE_CATEGORIES)} categories, {len(TRIAGE_URGENCIES)} urgencies") def classify_triage(text): """Primary triage signal (test macro-F1: 0.975 category / 0.766 urgency) -- reads the message's own content directly, rather than voting across nearest historical neighbors like recommend_similar() does.""" enc = triage_tokenizer(text, truncation=True, padding="max_length", max_length=128, return_tensors="pt") with torch.no_grad(): cat_logits, urg_logits = triage_model(enc["input_ids"], enc["attention_mask"]) cat = TRIAGE_CATEGORIES[cat_logits.argmax(dim=1).item()] urg = TRIAGE_URGENCIES[urg_logits.argmax(dim=1).item()] return cat, urg URGENCY_FULL = { "P1": "P1 Emergency (4h)", "P2": "P2 Urgent (24h)", "P3": "P3 Standard (3-5d)", "P4": "P4 Scheduled (7-14d)", } # Quick Starters + autocomplete QUICK_STARTERS_CACHE = {} AUTOCOMPLETE_PHRASES = [] TEXT_TO_CACHED = {} try: qs_path = hf_hub_download(repo_id=HF_REPO, filename="recommender/quick_starters_v2.json", repo_type="dataset", token=HF_TOKEN) with open(qs_path) as f: _qs_data = json.load(f) QUICK_STARTERS_CACHE = _qs_data.get("quick_starters", {}) AUTOCOMPLETE_PHRASES = _qs_data.get("autocomplete_phrases", []) TEXT_TO_CACHED = {v["tenant_message"].strip(): v for v in QUICK_STARTERS_CACHE.values()} print(f"Loaded {len(QUICK_STARTERS_CACHE)} Quick Starters, " f"{len(AUTOCOMPLETE_PHRASES)} autocomplete phrases.") except Exception as e: print(f"Could not load quick_starters_v2.json ({e}) — Quick Starters disabled.") # ── Recommender (FAISS + BGE) ───────────────────────────────────────────────── def _encode(text): prefix = cfg.get("query_prefix", "") return embed_model.encode( [prefix + text], normalize_embeddings=True, convert_to_numpy=True, ).astype("float32") def recommend_similar(query_text, top_k=3, building_id=None): qvec = _encode(query_text) sims_r, idxs_r = index.search(qvec, top_k * 3 + 1) cand = [(float(s), int(i)) for s, i in zip(sims_r[0], idxs_r[0]) if i >= 0 and s >= MIN_SIM] if not cand: return {"status": "no_match", "confidence": "low", "message": "No similar ticket found above the similarity threshold.", "similar_tickets": [], "contractor_rank": [], "classifier_check": None} if building_id: same = [c for c in cand if df.iloc[c[1]]["building_id"] == building_id] rest = [c for c in cand if df.iloc[c[1]]["building_id"] != building_id] cand = (same + rest)[:top_k] else: cand = cand[:top_k] similar = [] for sim, i in cand: row = df.iloc[i] similar.append({ "similarity": round(float(sim), 3), "ticket_id": str(row["ticket_id"]), "category": row["category"], "urgency": row["urgency"], "tenant_tone": row["tenant_tone"], "raw_text": row["raw_text"], "resolution_hours": float(row["resolution_hours"]), "cost_usd": float(row["cost_usd"]), "contractor_id": row["contractor_id"], "resolution_notes": row["resolution_notes"], }) counts = Counter(s["contractor_id"] for s in similar) contractor_rank = [ { "contractor_id": cid, "count": cnt, "specialty": next(s["category"] for s in similar if s["contractor_id"] == cid), "reason": f"Handled {cnt} of the top-{len(similar)} similar tickets", } for cid, cnt in counts.most_common() ] top_sim = cand[0][0] confidence = "high" if top_sim > 0.85 else "medium" if top_sim > 0.70 else "low" # Independent cross-check: the fine-tuned classifier predicts triage from # the raw text alone, with no knowledge of the FAISS neighbors at all. cls_category, cls_urgency = classify_triage(query_text) classifier_check = { "category": cls_category, "urgency": cls_urgency, "agrees_category": cls_category == similar[0]["category"], "agrees_urgency": cls_urgency == similar[0]["urgency"][:2], } return { "status": "ok", "confidence": confidence, "message": f"Top similarity: {top_sim:.1%}", "similar_tickets": similar, "contractor_rank": contractor_rank, "classifier_check": classifier_check, } # ── LLM generation (Qwen2.5) ───────────────────────────────────────────────── def _run_generate(gen_kwargs): try: with torch.no_grad(): gen_model.generate(**gen_kwargs) except Exception as e: print(f"[Generation error: {e}]") class _StopOnSecondRound(StoppingCriteria): """Halt generation the moment the model starts a SECOND WORK ORDER / TENANT REPLY round. It does this reliably, and those extra tokens were always discarded by _clean() anyway -- so on free CPU hardware this is a large slice of the wait being spent on output nobody ever sees.""" def __init__(self, tokenizer, prompt_len): self.tokenizer = tokenizer self.prompt_len = prompt_len def __call__(self, input_ids, scores, **kwargs): generated = self.tokenizer.decode( input_ids[0][self.prompt_len:], skip_special_tokens=True ) # Must NOT fire on the first, legitimate "TENANT REPLY:" header -- # doing so killed the reply before a single word of it was generated # and left the fallback text in its place. Only a header appearing # AFTER the reply has started is a genuine second round. tr = _TR_HDR.search(generated) if tr is None: # Reply hasn't started yet; a repeated WORK ORDER header is the # only thing that counts as a restart this early. return len(_WO_HDR.findall(generated)) > 1 return _SECOND_ROUND_HEADER.search(generated, tr.end()) is not None @spaces.GPU def _generate_stream(prompt, max_new_tokens=340, temperature=0.6): """Yields the growing generated text as tokens arrive, instead of blocking until the full response is done -- generate() runs in a background thread (it's synchronous) while the caller reads off the streamer, the standard transformers pattern for streaming with .generate(). ZeroGPU's @spaces.GPU supports generator functions the same way it supports plain ones.""" messages = [ {"role": "system", "content": "You are an assistant for a property management company."}, {"role": "user", "content": prompt}, ] text = gen_tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = gen_tokenizer(text, return_tensors="pt").to(gen_model.device) streamer = TextIteratorStreamer(gen_tokenizer, skip_prompt=True, skip_special_tokens=True) gen_kwargs = dict( **inputs, max_new_tokens=max_new_tokens, temperature=temperature, do_sample=True, top_p=0.9, repetition_penalty=1.1, pad_token_id=gen_tokenizer.eos_token_id, streamer=streamer, stopping_criteria=StoppingCriteriaList([ _StopOnSecondRound(gen_tokenizer, inputs["input_ids"].shape[-1]) ]), ) thread = Thread(target=_run_generate, args=(gen_kwargs,)) thread.start() partial = "" try: for chunk in streamer: partial += chunk yield partial finally: thread.join() if not partial.strip(): yield "[Generation error — please try again.]" _WO_HDR = re.compile(r"[\*\#\s]*work order[\*\#\s]*:[\*\#\s]*", re.IGNORECASE) _TR_HDR = re.compile(r"[\*\#\s]*tenant reply[\*\#\s]*:[\*\#\s]*", re.IGNORECASE) # The model is handed the real priority/SLA in its prompt but sometimes # paraphrases -- or outright hallucinates -- its own version inside the WORK # ORDER section it generates, contradicting the Triage card (which always # shows the fine-tuned classifier's real value). Strip whatever the model # wrote and replace it with the authoritative line so the two can never # disagree. # Anchoring this to the start of a line missed the case that actually reaches # the screen: when the model puts the whole priority block on ONE line # ("Priority Level and SLA Deadline: Priority: P1 Emergency (4 hours) ..."), # the inner "Priority:" is mid-line and survived -- so a P1 could sit under a # P2 headline. Matching anywhere fixes that, but only when a real priority # VALUE follows, so ordinary prose ("Set the Priority: high in the system") # keeps the rest of its sentence. _PRIORITY_LINE = re.compile( r"\s*-?\s*\bPriority\s*:\s*(?:P\s?[1-4]\b|Emergency|Urgent|Standard|Scheduled)[^\n]*", re.IGNORECASE, ) def _inject_authoritative_priority(work_order, urgency, sla): if not work_order: return work_order stripped = _PRIORITY_LINE.sub("", work_order).strip() header = f"Priority: {urgency} | SLA: {sla}" return f"{header}\n{stripped}" if stripped else header # The model reliably keeps generating past the first TENANT REPLY and starts a # whole second WORK ORDER / TENANT REPLY round (visible in the UI as the same # content written twice). Unlike a tone echo this has an unambiguous marker, # so it's safe to just cut there. _SECOND_ROUND_HEADER = re.compile( r"\n[\*\#\s]*(?:work\s*order(?:\s*#\s*\d+)?|tenant\s*(?:reply|response)|ticket\s*details)[\*\#\s]*:?", re.IGNORECASE, ) _LEADING_HDR = re.compile( r"^[\*\#\s]*(?:work\s*order|tenant\s*(?:reply|response))[\*\#\s]*:?[\*\#\s]*", re.IGNORECASE, ) _MD_BOLD = re.compile(r"\*\*(.+?)\*\*") _MD_ITALIC = re.compile(r"(?, not as markdown, so unstripped ** and --- show up literally in the UI.""" m = _SECOND_ROUND_HEADER.search(text) if m: text = text[:m.start()].rstrip() text = _MD_BOLD.sub(r"\1", text) text = _MD_ITALIC.sub(r"\1", text) text = re.sub(r"^\s*#+\s*", "", text, flags=re.MULTILINE) text = re.sub(r"^\s*-{3,}\s*$", "", text, flags=re.MULTILINE) text = _PLACEHOLDER_LINE.sub("", text) # Orphaned ** can survive the paired substitution above (the header regex # sometimes swallows an opening **, stranding its closing one). text = text.replace("**", "") return text.strip().strip("*#").strip() def _split_wo_reply_partial(raw): """Tolerant of a still-streaming partial text -- never substitutes fallback text here, since an empty section usually just hasn't been generated yet rather than having failed. The caller applies fallbacks once streaming is done (_split_wo_reply below).""" wo_m = _WO_HDR.search(raw) tr_m = _TR_HDR.search(raw) if wo_m and tr_m and tr_m.start() > wo_m.start(): wo = _clean(raw[wo_m.end():tr_m.start()]) tr = _clean(raw[tr_m.end():]) elif tr_m: wo = _clean(raw[:tr_m.start()]) tr = _clean(raw[tr_m.end():]) else: wo = _clean(raw) tr = "" # Safety net: in the `elif tr_m` branch (and whenever the model writes the # header in a form _WO_HDR doesn't match, e.g. without the colon), the # literal "WORK ORDER" line survives into the section body and renders as # a stray heading inside the card. Strip a leading header from each side. wo = _LEADING_HDR.sub("", wo, count=1).strip() tr = _LEADING_HDR.sub("", tr, count=1).strip() return wo, tr def _split_wo_reply(raw): wo, tr = _split_wo_reply_partial(raw) wo = wo or "Manual review required — auto-generation was inconclusive." tr = tr or "Thank you for reporting this — a technician has been assigned." return wo, tr # Words that pin a report to a place. "smoke in the stairs" names a place but # not WHICH stairwell or floor, so a bare hit here isn't enough on its own -- # it's combined with message length below. # Anything INSIDE the tenant's own unit is already fully located, because the # building and unit are captured in their own form fields -- "the bathroom # sink" plus unit 5B is an address. No need to ask where. _UNIT_ANCHOR = re.compile( r"\b(?:kitchen|bathroom|bedroom|living\s*room|closet|balcony|" r"sink|toilet|shower|tub|faucet|outlet|socket|radiator|boiler|" r"fridge|refrigerator|freezer|stove|oven|microwave|dishwasher|" r"washing\s*machine|dryer|ceiling|wall|window|door)\b", re.IGNORECASE, ) # Only the shared areas that REPEAT on every floor -- naming one of these # doesn't locate the fault. Deliberately excludes elevator, lobby, roof, # basement and garage: a building has one of each, so "the elevator is stuck" # needs no floor and asking for one would be pedantic. _COMMON_ANCHOR = re.compile( r"\b(?:stair(?:s|well|case)?|hallway|corridor|landing)\b", re.IGNORECASE, ) # Building systems there is exactly ONE of, so naming them locates the fault # on its own -- "the elevator is stuck" needs no further address. _SINGULAR_ANCHOR = re.compile( r"\b(?:elevator|lift|lobby|entrance|roof|garage|basement|" r"laundry\s*room|boiler\s*room|intercom|mailbox(?:es)?|gate)\b", re.IGNORECASE, ) # Tenants write floors as digits ("3rd floor") *and* as words ("second # floor") -- matching only digits flagged plainly-located reports as # unlocatable, which is exactly the false positive this whole function exists # to avoid. _FLOOR_HINT = re.compile( r"\b(?:\d+(?:st|nd|rd|th)?\s*floor|floor\s*\d+|\d+(?:st|nd|rd|th)\b" r"|(?:ground|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|" r"tenth|top|lower|upper)\s*floor" r"|basement|penthouse)\b", re.IGNORECASE, ) def _missing_details(text): """What a dispatcher would still have to phone the tenant to ask. Returns [] when the report is already actionable. Calibrated against the 13,725-ticket dataset: an earlier "<18 words" rule flagged 4.6% of real tickets, including clearly actionable ones like "Water leak in the bathroom ceiling is causing mold growth" -- asking those for a location would make the product look broken. This version flags ~0.8%, and only where a dispatcher genuinely couldn't proceed.""" t = (text or "").strip() words = len(t.split()) has_floor = bool(_FLOOR_HINT.search(t)) # A per-floor shared area named without a floor: the one case where even a # long, otherwise-detailed report still can't be dispatched. if _COMMON_ANCHOR.search(t) and not has_floor: return ["which floor"] # Anything that pins the fault to a place: a unit interior (the unit # itself comes from the form field), a one-per-building system, or an # explicit floor. if _UNIT_ANCHOR.search(t) or _SINGULAR_ANCHOR.search(t) or has_floor: return [] if words >= 12: return [] # substantive enough to act on return ["what is affected and where", "when it started and whether it is getting worse"] def generate_ticket_response_stream(tenant_message, category, urgency, tenant_tone, building_id, unit, similar_tickets=None, contractor=None): """Yields (work_order, tenant_reply) as the model streams, instead of blocking until the full response is done.""" sla = SLA_MAP.get(urgency[:2], "TBD") tone_instruction = TONE_INSTRUCTIONS.get(tenant_tone, TONE_INSTRUCTIONS["polite-formal"]) past_case = "" if similar_tickets: t = similar_tickets[0] past_case = (f"- Similar past case: {t['raw_text'][:120]}... " f"(resolved in {t['resolution_hours']:.0f}h, ${t['cost_usd']:.0f})\n") contractor_info = ( f"- Suggested contractor: {contractor['contractor_id']} ({contractor['specialty']})\n" if contractor else "" ) # A thin report can't produce a useful reply -- the honest response is to # ask for what's missing. But for a P1 hazard, asking INSTEAD of acting # would be dangerous, so there the question is additive: confirm help is # coming first, then ask. missing = _missing_details(tenant_message) if missing: joined = "; ".join(missing) if urgency[:2] == "P1": reply_spec = ( "" ) else: reply_spec = ( "" ) else: reply_spec = ( "<2-4 sentence reply to tenant, matching tone; no internal IDs, contractor " "names, or priority/urgency codes -- just describe when to expect help in " "plain language>" ) prompt = ( "Produce exactly two labeled sections (no markdown on the headers themselves):\n\n" "WORK ORDER:\n" f"\n\n" "TENANT REPLY:\n" f"{reply_spec}\n\n" f"DETAILS:\n" f"- Building: {building_id}, Unit: {unit}\n" f"- Category: {category}\n" f"- Priority: {urgency} (SLA: {sla}) -- this is FIXED, already decided by the triage " f"system; do not re-assess or change it, just use it as given\n" f"- Tenant report: {tenant_message}\n" f"- Tone: {tenant_tone} — {tone_instruction}\n" f"{past_case}{contractor_info}" ) work_order, tenant_reply = "", "" for partial in _generate_stream(prompt, max_new_tokens=340, temperature=0.6): work_order, tenant_reply = _split_wo_reply_partial(partial) work_order = _inject_authoritative_priority(work_order, urgency, sla) yield work_order, tenant_reply # Final pass -- fallback text only if a section is still genuinely empty # now that streaming has finished, not just "hasn't arrived yet". if not work_order: work_order = "Manual review required — auto-generation was inconclusive." if not tenant_reply: # A generic "a technician has been assigned" is the worst thing to send # when generation failed on a thin report -- it promises action while # asking for nothing. Fall back to the question instead. if missing: ask = missing[0] tenant_reply = ( "Thanks for flagging this — help is on the way now. " f"So we can get to you faster, could you tell us {ask}?" if urgency[:2] == "P1" else "Thanks for letting us know. To get someone out to the right place, " f"could you tell us {ask}?" ) else: tenant_reply = "Thank you for reporting this — a technician has been assigned." yield work_order, tenant_reply # ── Full pipeline ───────────────────────────────────────────────────────────── def run_pipeline_stream(tenant_message, building_id="", unit="N/A"): """Generator version of run_pipeline: yields a growing result dict as generation streams in, with generating=True until the final yield. The no_match case short-circuits before ever calling the LLM.""" building_id = building_id.strip() or None unit = unit.strip() or "N/A" rec = recommend_similar(tenant_message, top_k=3, building_id=building_id) similar = rec["similar_tickets"] contractor = rec["contractor_rank"][0] if rec["contractor_rank"] else None # CATEGORY comes from the classifier (it reads the message directly and # scores 0.975 macro-F1). URGENCY is merged fail-safe across both signals # -- see _merge_urgency for why neither one alone can be trusted to lower # a priority. cc = rec.get("classifier_check") category = (cc["category"] if cc else (similar[0]["category"] if similar else "Unknown")) cls_code = cc["urgency"] if cc else None retr_code = similar[0]["urgency"][:2] if similar else None code, escalation_reason = _merge_urgency(cls_code, retr_code, tenant_message) urgency = URGENCY_FULL.get(code, code) tone = similar[0]["tenant_tone"] if similar else "polite-formal" base = { "triage": {"category": category, "urgency": urgency, "tone": tone, "escalation_reason": escalation_reason}, "retrieval": rec, "contractor": contractor, "tenant_message": tenant_message, "building_id": building_id or "Unknown", "unit": unit, } if rec["status"] == "no_match": yield {**base, "work_order": "", "tenant_reply": "", "generating": False} return # First yield: retrieval/triage/classifier already computed (a few ms), # generation not started yet -- the UI can show those immediately. yield {**base, "work_order": "", "tenant_reply": "", "generating": True} work_order, tenant_reply = "", "" for work_order, tenant_reply in generate_ticket_response_stream( tenant_message, category, urgency, tone, building_id or "Unknown", unit, similar, contractor, ): yield {**base, "work_order": work_order, "tenant_reply": tenant_reply, "generating": True} yield {**base, "work_order": work_order, "tenant_reply": tenant_reply, "generating": False} # ── HTML timeline rendering ─────────────────────────────────────────────────── def esc(s) -> str: return html.escape(str(s or "")) def _empty_timeline(hint): """Skeleton of the real answer instead of one dim line in an empty box, so the shape of the output is legible before any data exists. The closing hint is a parameter because the two tabs are driven by different controls: tab 1 has Quick Starters and a message box, the 311 tab has neither. Both tabs used to share the tab-1 wording, which pointed at buttons that do not exist on the 311 screen.""" return ( '
' '
' '

Triage

' '
' '
' '
' '
' '

Similar past tickets

' '
' '
' '
' '
' '
' '

Work order & tenant reply

' '
' '
' '
' '

' + hint + '

' '
' ) _EMPTY_TIMELINE = _empty_timeline( "Pick a Quick Starter or paste a tenant message to fill these in.") _EMPTY_TIMELINE_311 = _empty_timeline( "Select a row in the live feed above, then press " "“Analyze selected complaint” to fill these in.") def _render_timeline(result): t = result["triage"] category = t["category"] urgency = t["urgency"] tone = t["tone"] similar = result["retrieval"]["similar_tickets"] contractor = result.get("contractor") building = result["building_id"] unit = result["unit"] text = result["tenant_message"] code = urgency[:2] urgency_class = f"pp-urgency-{code.lower()}" icon = CATEGORY_ICONS.get(category, "📋") sla = SLA_MAP.get(code, "TBD") urg_emoji = URGENCY_EMOJI.get(code, "") conf_label = {"high": "✅ High", "medium": "⚠️ Medium", "low": "❓ Low"}.get( result["retrieval"]["confidence"], result["retrieval"]["confidence"] ) # Headline category/urgency above is the fine-tuned classifier's call # (see run_pipeline) -- this line shows retrieval's independent nearest- # neighbor pick as a cross-check, so a disagreement is visible rather # than silently overridden. # Both signals are shown explicitly. The headline is a MERGE of the two, # so hiding either one would make the displayed priority unexplainable -- # an operator has to be able to see what each model actually said. cc = result["retrieval"].get("classifier_check") cross_check_html = "" if cc: rows = [ ("🤖", "DistilBERT classifier", f'{esc(cc["category"])} · {esc(cc["urgency"])}'), ] if similar: rows.append(("📋", "Nearest neighbours", f'{esc(similar[0]["category"])} · {esc(similar[0]["urgency"][:2])}')) cross_check_html = ( '
' + "".join( f'
' f'{ico}' f'{name}' f'{val}' f'
' for ico, name, val in rows ) + "
" ) # Similar tickets rows # Collapsed to a one-line preview by default, but the FULL ticket text and # its resolution are one click away -- previously the text was hard-cut at # 110 chars with no way to read the rest, which is the most useful part # when you're deciding whether the match is actually relevant. sim_rows = "".join( f'
' f'' f'{esc(s["ticket_id"])}' f'{esc(s["raw_text"][:110])}' f'{"…" if len(s["raw_text"]) > 110 else ""}' f'{s["similarity"]:.0%}' f'' f'
' f'

{esc(s["raw_text"])}

' f'

' f'{esc(s["category"])} · {esc(s["urgency"][:2])} · ' f'resolved in {s["resolution_hours"]:.0f}h · ${s["cost_usd"]:.0f}' f'{" · " + esc(s["resolution_notes"]) if s.get("resolution_notes") else ""}' f'

' f'
' f'
' for s in similar ) or '
No close matches found.
' contractor_html = "" if contractor: contractor_html = ( f'
' f'🏗️ Recommended: {esc(contractor["contractor_id"])} ' f'({esc(contractor["specialty"])}) — {esc(contractor["reason"])}' f'
' ) # An automatic escalation must never be silent -- the operator has to be # able to see that a model was overruled, and why. escalation_html = "" reason = t.get("escalation_reason") if reason: # A forced escalation is louder than a mere disagreement note. is_escalation = reason.startswith("Escalated") cls = "pp-escalated" if is_escalation else "pp-disagreed" mark = "▲" if is_escalation else "⚠" escalation_html = f'

{mark} {esc(reason)}

' flink = fiverr_url(category) fiverr_html = ( f'' f'🔗 Find a {esc(category)} contractor on Fiverr' ) return f"""
{esc(building)} / {esc(unit)}

{esc(text)}

Triage

{icon}

{esc(category)} · {code}

{esc(_deadline_label(code))}
{escalation_html}

Tone: {esc(tone)}  ·  Confidence: {conf_label}

{cross_check_html}
{fiverr_html}

Similar past tickets

{sim_rows} {contractor_html}
""" # A section header is a SHORT label followed by a colon, with the numbering # optional and the content allowed to continue on the same line. The previous # pattern required `1. Label:` on a line of its own -- a shape this model does # not actually produce, so the parser matched nothing and every work order # silently fell through to the raw
 fallback. Verified against real
# output: "Issue Description: ..." and "Action Steps:" now both parse.
# A four-word cap used to guard against prose-with-a-colon, but it silently
# rejected every LONGER real heading -- "Priority Level and SLA Deadline" (5)
# and "Relevant Context from the Similar Past Case" (7). Those then fell
# through as body text into whichever section came before, which is what put
# a stray P1 in the lead paragraph and context prose in the action checklist.
# Title-case is the honest discriminator: this model's headings are title
# case, its prose is not. Verified on 8 real headings and 4 prose lines.
_WO_SECTION_RE = re.compile(
    r"^\s*(?:\d+[.)]\s*)?([A-Za-z][A-Za-z/&'\- ]{0,48}?)\s*:\s*(.*)$"
)
_WO_MINOR_WORDS = {"a", "an", "the", "and", "or", "of", "for", "from",
                   "in", "on", "to", "with", "by", "at", "as"}


def _is_section_heading(label):
    words = label.split()
    if not words or len(words) > 8:
        return False
    return all(w.lower() in _WO_MINOR_WORDS or w[0].isupper() for w in words)


# A genuine list item carries a marker. Everything else under an "Actions"
# heading is prose, and must NOT become a tickable checkbox -- that is how
# "Relevant Context from the Similar Past Case:" ended up as an action.
_WO_LIST_ITEM = re.compile(r"^\s*(?:[-*\u2022]|\d+[.)])\s+(.*)$")


def _format_work_order(text):
    """The model already emits four labelled sections. The old renderer threw
    that structure away and re-flattened it into a 
 wall, which is the
    single least usable thing on the page -- an operator has to work FROM this
    document. Parse it back into UI: a lead sentence, a tickable checklist of
    actions, and the past case as a quiet footnote. Priority/SLA is dropped
    because it is already the headline pill on the Triage card.
    """
    raw = (text or "").strip()
    if not raw:
        return '

No work order generated.

' # Items are (text, is_list_item) so the Actions bucket can keep only real # list entries. sections, current = [], None for line in raw.splitlines(): m = _WO_SECTION_RE.match(line) if m and _is_section_heading(m.group(1).strip()): # Content may sit on the same line as its label # ("Issue Description: There is smoke ..."), so seed the section # with it instead of discarding it. inline = m.group(2).strip() current = (m.group(1).strip(), [(inline, False)] if inline else []) sections.append(current) continue if current is None: continue li = _WO_LIST_ITEM.match(line) body = (li.group(1) if li else line).strip() if body: current[1].append((body, bool(li))) # Unrecognised shape (the generator is a 1.5B model and does drift) -- # fall back to the raw text rather than silently dropping content. if not sections: return f'
{esc(raw)}
' lead, steps, context = [], [], [] for title, items in sections: key = title.lower() if "priority" in key or "sla" in key: continue if "action" in key or "instruction" in key or "step" in key: # Only marked list entries become tickable actions; any prose the # model wrote under the heading is description, not a task. steps.extend(t for t, is_item in items if is_item) lead.extend(t for t, is_item in items if not is_item) elif "context" in key or "past" in key or "similar" in key: context.extend(t for t, _ in items) else: lead.extend(t for t, _ in items) out = [] if lead: out.append(f'

{esc(" ".join(lead))}

') if steps: out.append('

Actions

    ') out += [ f'
  • {esc(s)}
  • ' for s in steps ] out.append("
") if context: out.append('

From the closest past case

') out.append(f'

{esc(" ".join(context))}

') return "".join(out) or f'
{esc(raw)}
' def _render_result_cards(work_order, reply, urgency_class=""): """Two separate cards: these are two documents for two different audiences -- one goes to the contractor, one goes to the tenant.""" return f"""

Work order · for the contractor

{_format_work_order(work_order)}

Tenant reply · ready to send

{esc(reply)}
""" def _result_to_outputs(result): _code = result["triage"]["urgency"][:2] timeline = _render_timeline(result) + _render_result_cards( result["work_order"], result["tenant_reply"], urgency_class=f"pp-urgency-{_code.lower()}", ) # The verdict inside the Triage card IS the headline now -- this Markdown # line above the stack was the same string a second time, one step smaller. triage_lbl = "" result_json = json.dumps(result, default=str) return timeline, result["work_order"], result["tenant_reply"], triage_lbl, result_json # ── Cache helpers ───────────────────────────────────────────────────────────── def _render_from_cache(tenant_message, building_id, unit, cached, status_msg): rec = recommend_similar(tenant_message, top_k=3, building_id=building_id) similar = rec["similar_tickets"] contractor = rec["contractor_rank"][0] if rec["contractor_rank"] else None cc = rec.get("classifier_check") cat = (cc["category"] if cc else (similar[0]["category"] if similar else "Unknown")) _cls = cc["urgency"] if cc else None _retr = similar[0]["urgency"][:2] if similar else None _code, _reason = _merge_urgency(_cls, _retr, tenant_message) urg = URGENCY_FULL.get(_code, _code) tone = similar[0]["tenant_tone"] if similar else "polite-formal" result = { "triage": {"category": cat, "urgency": urg, "tone": tone, "escalation_reason": _reason}, "retrieval": rec, "contractor": contractor, "work_order": cached.get("work_order", ""), "tenant_reply": cached.get("tenant_reply", ""), "tenant_message": tenant_message, "building_id": building_id, "unit": unit, } tl, wo, rp, lbl, rj = _result_to_outputs(result) return tl, wo, rp, lbl, status_msg, rj def use_cached_quick_starter(category): cached = QUICK_STARTERS_CACHE.get(category) if not cached: return ("", "B-01", "1A", _EMPTY_TIMELINE, "", "", "", "Quick Starter not available.", None) tm = cached["tenant_message"] bid = cached.get("building_id", "B-01") u = cached.get("unit", "1A") tl, wo, rp, lbl, status, rj = _render_from_cache(tm, bid, u, cached, "Loaded from cache — instant!") return tm, bid, u, tl, wo, rp, lbl, status, rj # ── Gradio process function ─────────────────────────────────────────────────── # Minimum gap between UI updates while a response is streaming in. Individual # streamer chunks can arrive many times a second; without this, a 340-token # response would push ~340 websocket updates. 120ms keeps the "growing text" # feel smooth without spamming the connection. _STREAM_THROTTLE_S = 0.12 def process(tenant_message, building_id, unit): if not tenant_message or not tenant_message.strip(): yield _EMPTY_TIMELINE, "", "", "", "Please enter a tenant message.", None return cached = TEXT_TO_CACHED.get(tenant_message.strip()) if cached: bid = building_id.strip() or cached.get("building_id", "B-01") u = unit.strip() or cached.get("unit", "1A") tl, wo, rp, lbl, status, rj = _render_from_cache( tenant_message.strip(), bid, u, cached, "Matched a known ticket — instant cached result!" ) yield tl, wo, rp, lbl, status, rj return # Retrieval + both classifiers run before the stream's first yield. That # gap used to be covered only by Gradio's own overlay and its 0.0s counter # painted over the output column. Showing the skeleton instead keeps the # shape of the answer on screen and says what is actually happening. yield (_empty_timeline("Retrieving similar tickets and running triage…"), "", "", "", "🔍 Analyzing…", None) last_yield_at = 0.0 for result in run_pipeline_stream(tenant_message, building_id, unit): if result["retrieval"]["status"] == "no_match": yield ( '

' "⚠️ Input doesn't resemble a maintenance request.

", "", "", "", "⚠️ Not recognized as a maintenance request — please describe a specific issue.", None, ) return now = time.monotonic() is_final = not result["generating"] if not is_final and (now - last_yield_at) < _STREAM_THROTTLE_S: continue # skip this chunk, the next one (or the final yield) will catch up last_yield_at = now status = "✍️ Generating…" if result["generating"] else "Done!" tl, wo, rp, lbl, rj = _result_to_outputs(result) if is_final: _auto_send_urgency(result) yield tl, wo, rp, lbl, status, (rj if is_final else None) def clear_all(): return ("", "B-01", "1A", _EMPTY_TIMELINE, "", "", "", "", None) # ── Slack ───────────────────────────────────────────────────────────────────── def _auto_send_urgency(result): """Silently post to the urgency-specific Slack channel after every pipeline run.""" code = result["triage"]["urgency"][:2] webhook = SLACK_WEBHOOKS.get(code) if not webhook: return c = result.get("contractor") con_text = f"{c['contractor_id']} ({c['specialty']}) — {c['reason']}" if c else "N/A" sim_lines = chr(10).join( f"[{t['similarity']:.2f}] {t['category']} — {t['raw_text'][:70]}..." for t in result["retrieval"]["similar_tickets"][:3] ) or "None" payload = {"attachments": [{ "color": URGENCY_HEX.get(code, "#7289DA"), "title": f"PropertyPilot — New Ticket {URGENCY_EMOJI.get(code,'')} {code}", "fields": [ {"title": "Location", "value": f"Building {result['building_id']} | Unit {result['unit']}", "short": True}, {"title": "Category", "value": result["triage"]["category"], "short": True}, {"title": "Tone", "value": result["triage"]["tone"], "short": True}, {"title": "SLA", "value": SLA_MAP.get(code, "TBD"), "short": True}, {"title": "Tenant Message", "value": result["tenant_message"][:300]}, {"title": "Similar Tickets", "value": sim_lines}, {"title": "Contractor", "value": con_text}, {"title": "Work Order", "value": result["work_order"][:400]}, {"title": "Tenant Reply", "value": result["tenant_reply"][:400]}, ], }]} try: requests.post(webhook, json=payload, timeout=10) except Exception: pass def send_to_slack(result_json): if not result_json: return "Run the pipeline first." if not SLACK_WEBHOOK: return "Slack not configured — add SLACK_WEBHOOK_URL to Space secrets." result = json.loads(result_json) if isinstance(result_json, str) else result_json code = result["triage"]["urgency"][:2] c = result.get("contractor") con_text = f"{c['contractor_id']} ({c['specialty']}) — {c['reason']}" if c else "N/A" sim_lines = "\n".join( f"[{t['similarity']:.2f}] {t['category']} — {t['raw_text'][:70]}..." for t in result["retrieval"]["similar_tickets"][:3] ) or "None" payload = {"attachments": [{ "color": URGENCY_HEX.get(code, "#7289DA"), "title": f"PropertyPilot — New Ticket {URGENCY_EMOJI.get(code,'')} {code}", "fields": [ {"title": "Location", "value": f"Building {result['building_id']} | Unit {result['unit']}", "short": True}, {"title": "Category", "value": result["triage"]["category"], "short": True}, {"title": "Tone", "value": result["triage"]["tone"], "short": True}, {"title": "SLA", "value": SLA_MAP.get(code, "TBD"), "short": True}, {"title": "Tenant Message", "value": result["tenant_message"][:300]}, {"title": "Similar Tickets", "value": sim_lines}, {"title": "Contractor", "value": con_text}, {"title": "Work Order", "value": result["work_order"][:400]}, {"title": "Tenant Reply", "value": result["tenant_reply"][:400]}, ], }]} try: r = requests.post(SLACK_WEBHOOK, json=payload, timeout=10) return "Dispatched to Slack ops channel!" if r.status_code == 200 else f"Slack error {r.status_code}: {r.text[:200]}" except Exception as e: return f"Request failed: {e}" # ── NYC-311 live feed ───────────────────────────────────────────────────────── _NYC311_TYPES = ( "'HEAT/HOT WATER','PLUMBING','ELECTRIC','ELEVATOR'," "'PAINT/PLASTER','WATER LEAK','DOOR/WINDOW','FLOORING/STAIRS'" ) def fetch_nyc311(): url = ( "https://data.cityofnewyork.us/resource/erm2-nwe9.json" f"?$limit=25&$order=created_date+DESC" f"&$where=complaint_type+IN+({_NYC311_TYPES})" ) fetched_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: r = requests.get(url, timeout=15) r.raise_for_status() data = r.json() rows = [{ "Reported": d.get("created_date", "")[:10], "Borough": d.get("borough", "-").title(), "Complaint": d.get("complaint_type", ""), "Detail": d.get("descriptor", ""), "Status": d.get("status", ""), "Last Updated": (d.get("resolution_action_updated_date") or d.get("created_date", ""))[:16].replace("T", " "), } for d in data] # Third return value is the raw API payload, stashed in a gr.State so # a later "analyze row N" resolves against exactly these rows rather # than re-fetching a feed that may have shifted underneath it. return (pd.DataFrame(rows), f"Loaded {len(rows)} complaints · refreshed {fetched_at}", data) except Exception as e: empty = pd.DataFrame(columns=["Reported", "Borough", "Complaint", "Detail", "Status", "Last Updated"]) return empty, f"Error: {e} · {fetched_at}", [] def _complaint_to_text(row): """NYC-311 rows have no free-text tenant message -- they're structured fields -- so synthesise the closest equivalent for the pipeline.""" return (f"{row.get('complaint_type','')}: {row.get('descriptor','')} " f"at {row.get('incident_address','')}") def on_311_select(rows, evt: gr.SelectData): """Row-click handler: records which row was picked and echoes it back, so the selection is visible instead of the user counting rows from zero.""" if not rows or evt is None or evt.index is None: return -1, "_Click any row in the table below to select it._" idx = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index if not (0 <= idx < len(rows)): return -1, "_Selection is out of range — hit Refresh Feed and try again._" row = rows[idx] return idx, (f"**Selected row {idx + 1}:** {row.get('complaint_type','')} — " f"{row.get('descriptor','')} · {row.get('incident_address','')}") def process_311_row(row_index, rows): try: idx = int(row_index) if row_index is not None else -1 if not rows: yield (_EMPTY_TIMELINE_311, "", "", "", "Feed not loaded yet — hit Refresh Feed first.", None) return if not (0 <= idx < len(rows)): yield (_EMPTY_TIMELINE_311, "", "", "", "Select a complaint by clicking a row in the table above.", None) return row = rows[idx] yield from process(_complaint_to_text(row), row.get("incident_address", "NYC")[:20], "311") except Exception as e: yield _EMPTY_TIMELINE_311, "", "", "", f"Error processing 311 row: {e}", None # ── Autocomplete ────────────────────────────────────────────────────────────── N_SUGGESTIONS = 5 def get_suggestions(text): text = (text or "").strip() if not text or not AUTOCOMPLETE_PHRASES: return [gr.update(value="", visible=False) for _ in range(N_SUGGESTIONS)] + [[]] t = text.lower() starts = [p for p in AUTOCOMPLETE_PHRASES if p.lower().startswith(t)] contains = [p for p in AUTOCOMPLETE_PHRASES if t in p.lower() and p not in starts] matches = (starts + contains)[:N_SUGGESTIONS] labels = [(m[:55] + "...") if len(m) > 55 else m for m in matches] updates = [ gr.update(value=labels[i], visible=True) if i < len(matches) else gr.update(value="", visible=False) for i in range(N_SUGGESTIONS) ] return updates + [matches] def _make_fill_fn(idx): def fill(matches): if matches and idx < len(matches): return matches[idx] return gr.update() return fill # ── CSS ─────────────────────────────────────────────────────────────────────── custom_css = """ /* ══════════════════════════════════════════════════════════════════════════ PropertyPilot — design system --------------------------------------------------------------------------- ONE radius set, ONE shadow, ONE accent, FIVE type steps, a 4px spacing scale. Priority (P1-P4) is the only polychrome scale in the product. The previous sheet stacked up to five bordered containers at any point on screen (Gradio .block > .pp-panel > .pp-card > .pp-signals/.pp-sim-full), each with its own radius (14/12/10/8/7/999px) and its own outline. Nothing was louder than anything else, so the page read as a filing cabinet. Here grouping is carried by ELEVATION and WHITESPACE; outlines survive only as true dividers between rows. ══════════════════════════════════════════════════════════════════════════ */ /* ── Tokens: surfaces + text ─────────────────────────────────────────────── */ :root { --pp-bg: #f6f7fc; --pp-panel: #ffffff; --pp-card: #ffffff; --pp-card-2: #f2f4fa; /* recessed/nested surface, replaces sub-boxes */ --pp-border: #e6e9f2; --pp-text: #14172a; --pp-text-dim: #545c73; /* 3.73:1 on the recessed surface. Darkened along its own hue until the worst-case surface clears 4.5:1; still clearly the quietest grey. */ --pp-text-muted: #696f83; --pp-blue: #2f5fe0; --pp-blue-hover: #2450c9; --pp-blue-soft: #eaf0fd; --pp-btn-bg: #eef1f8; --pp-btn-border: #e2e6f1; --pp-sim-score: #147f53; /* was #17915f, 3.64:1 on --pp-card-2 */ /* Per-section label colours. The obvious #c23f77 measured 4.39:1 on the card surface -- just under the bar -- so the light-mode pink is a shade deeper at 5.30:1. */ --pp-label-purple: #4a3cc4; --pp-label-triage: #1a5fa8; --pp-label-pink: #9c2456; /* Body copy in the results column + ghost button labels. Gradio rewrites custom selectors with a high-specificity container prefix but leaves `.dark ...` ones alone, so a `.dark` override loses. Flipping a token instead means the ONE prefixed rule resolves per theme. */ --pp-strong: #14172a; --pp-ghost-fg: #545c73; /* Label colour for anything FILLED with --pp-blue. It was hard-coded #fff, which is right on the light theme's deep blue (5.48:1) but wrong on the dark theme's light blue -- white on #73a1ff measured 2.54:1, the worst contrast in the app. The accent itself is fine in both themes; only the ink on top of it has to flip. */ --pp-on-blue: #ffffff; --pp-shadow: 0 1px 2px rgba(20,23,42,.06), 0 6px 16px -8px rgba(20,23,42,.13); --pp-shadow-sm: 0 1px 2px rgba(20,23,42,.05); } .dark { /* A navy ground rather than near-black: chroma 35 vs the previous 11, so the page reads as deliberately blue instead of as an unlit grey. The surfaces above it are lifted to match, keeping each step distinguishable. */ --pp-bg: #132036; --pp-panel: #182842; --pp-card: #1d2f4d; --pp-card-2: #24385a; --pp-border: #2d456b; --pp-text: #e9edf6; --pp-text-dim: #9aa4bb; --pp-text-muted: #96a3c2; --pp-blue: #73a1ff; --pp-blue-hover: #5b8bf5; --pp-blue-soft: #16233b; --pp-btn-bg: #1d2434; --pp-btn-border: #2b3449; --pp-sim-score: #7bdc9f; --pp-label-purple: #b9b3f2; --pp-label-triage: #7bc0ff; --pp-label-pink: #f0a8c2; --pp-strong: #ffffff; --pp-ghost-fg: #ffffff; --pp-on-blue: #0d1626; /* navy ink on the light-blue accent: 7.13:1 */ --pp-shadow: 0 1px 2px rgba(0,0,0,.30), 0 8px 20px -10px rgba(0,0,0,.55); --pp-shadow-sm: 0 1px 2px rgba(0,0,0,.30); } /* ── Tokens: scale ───────────────────────────────────────────────────────── */ /* --pp-accent-a/-b were referenced by .pp-quick-row:hover, .pp-qs-selected and --border-color-accent but never DEFINED, so those declarations were invalid and silently dropped -- which is why the selected Quick Starter never actually filled in. Both now resolve to the single accent. */ :root, .dark { --pp-accent-a: var(--pp-blue); --pp-accent-b: var(--pp-blue-hover); --pp-r: 14px; --pp-r-sm: 10px; --pp-r-pill: 999px; /* Was 13 distinct sizes between 10px and 26px, several at half-pixel values (12.5/13.5) -- a tell that the design grew by local adjustment rather than from a system. Five steps. */ --pp-fs-1: 11px; /* uppercase micro-label */ --pp-fs-2: 13px; /* secondary / meta / rows */ --pp-fs-3: 15px; /* body */ --pp-fs-4: 20px; /* hero title */ --pp-fs-5: 28px; /* the verdict */ --pp-s1: 4px; --pp-s2: 8px; --pp-s3: 12px; --pp-s4: 16px; --pp-s5: 24px; --pp-s6: 32px; --pri-p1: #ff5f6d; --pri-p2: #ffb020; --pri-p3: #4aa8ff; --pri-p4: #7c8aa5; } /* ── Gradio's own theme variables, pointed at the tokens above ───────────── */ /* Native components (Textbox, Button, DataFrame) render from Gradio's OWN variable names, independent of --pp-*. Native wrappers are made INVISIBLE here: their fill + border were the outermost of the nested boxes. Surfaces are now declared explicitly, once, by .pp-card and .table-wrap. */ :root, .dark { --body-background-fill: var(--pp-bg); --body-text-color: var(--pp-text); --body-text-color-subdued: var(--pp-text-dim); --background-fill-primary: var(--pp-bg); --background-fill-secondary: var(--pp-card-2); --block-background-fill: transparent; --block-border-width: 0px; --block-border-color: transparent; --block-shadow: none; --block-label-background-fill: transparent; --block-label-text-color: var(--pp-text-dim); --block-title-text-color: var(--pp-text-dim); --block-title-text-weight: 500; --panel-background-fill: transparent; --panel-border-width: 0px; --input-background-fill: var(--pp-card-2); --input-border-color: var(--pp-border); --input-border-color-focus: var(--pp-blue); --input-radius: var(--pp-r-sm); --input-shadow: none; --border-color-primary: var(--pp-border); --border-color-accent: var(--pp-blue); /* The selected tab's underline stayed orange because it is painted from Gradio's OWN accent variables, which nothing here had redefined -- the `.tab-nav button.selected` rule below only ever reached the text colour. Redirecting the accent itself is what actually moves the underline. */ --color-accent: var(--pp-blue); --color-accent-soft: var(--pp-blue-soft); --button-secondary-background-fill: var(--pp-btn-bg); --button-secondary-text-color: var(--pp-text); --button-secondary-border-color: var(--pp-btn-border); --button-large-radius: var(--pp-r-pill); --button-small-radius: var(--pp-r-pill); --radius-sm: var(--pp-r-sm); --radius-lg: var(--pp-r); --table-border-color: var(--pp-border); --table-even-background-fill: var(--pp-card); --table-odd-background-fill: var(--pp-card-2); } .gradio-container { background: var(--pp-bg) !important; color: var(--pp-text) !important; max-width: 100% !important; -webkit-font-smoothing: antialiased; } footer { visibility: hidden; } /* ── Hero: the single brand moment ──────────────────────────────────────── */ .pp-hero { background: linear-gradient(135deg, #3b2ea8 0%, #6d5cf5 52%, #8f66ff 100%); border: none; border-radius: var(--pp-r); padding: 18px 22px; margin-bottom: var(--pp-s4); } /* The `*` matters: Gradio's `.prose *` rule paints every DESCENDANT with --body-text-color, so wrapping the title in a for the flex layout handed it a dark colour in light mode. Raw text in the h1 was immune; an element child is not. */ .pp-hero h1, .pp-hero h1 * { color: #fff; } .pp-hero h1 { font-size: var(--pp-fs-4); margin: 0 0 var(--pp-s1); font-weight: 620; letter-spacing: -.015em; display: flex; align-items: center; gap: 10px; } /* Inline SVG rather than an image file: it inherits `currentColor` from the heading, so it works on the hero gradient and would work on any future ground without shipping a second asset. */ .pp-logo { width: 27px; height: 27px; flex-shrink: 0; display: block; /* Gradio's `.prose *` sets --body-text-color on every descendant, which beats inheriting the h1's white -- so the mark went dark in light mode on the purple hero. Forced explicitly. */ color: #fff; } .pp-hero p { margin: 0; color: rgba(255,255,255,.80); font-size: var(--pp-fs-2); line-height: 1.55; max-width: 70ch; } .pp-badges { margin-top: var(--pp-s3); display: flex; gap: var(--pp-s2); flex-wrap: wrap; } .pp-stat-badge { background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.22); color: #fff; border-radius: var(--pp-r-pill); padding: 3px 11px; font-size: var(--pp-fs-1); font-weight: 500; } /* ── Columns contribute LAYOUT ONLY ─────────────────────────────────────── */ /* Both columns were bordered panels wrapping already-bordered blocks. */ .pp-panel { background: transparent !important; border: none !important; box-shadow: none !important; border-radius: 0 !important; padding: 0 !important; gap: var(--pp-s4) !important; } /* ── Quick Starters ─────────────────────────────────────────────────────── */ /* flex:1 1 0 gives equal widths. They were shrink-wrapping their labels, so two rows of five came out ragged -- the messiest area on the page. */ /* `flex: 1 1 0` forces every pill to the same width and lets them shrink, but `white-space: nowrap` means the label can't shrink with them -- so a long label like "Common Areas" overflowed its own button and ran into the next one. Sizing to content and letting the row wrap fixes it without truncating any label. */ .pp-quick-row { gap: 6px !important; flex-wrap: wrap !important; align-items: center; } .pp-quick-row button { flex: 0 1 auto !important; width: auto !important; min-width: 0 !important; max-width: 100% !important; background: var(--pp-btn-bg) !important; border: 1px solid var(--pp-btn-border) !important; color: var(--pp-text-dim) !important; border-radius: var(--pp-r-pill) !important; /* Tight enough that all five pills fit on one line in the input column, so the two gr.Rows render as exactly two lines. */ font-size: 11.5px !important; font-weight: 500 !important; padding: 4px 9px !important; line-height: 1.3 !important; min-height: 0 !important; white-space: nowrap; transition: background .15s, border-color .15s, color .15s; } .pp-quick-row button { color: var(--pp-strong) !important; } .pp-quick-row button:hover { border-color: var(--pp-blue) !important; color: var(--pp-blue) !important; } .pp-qs-selected { background: var(--pp-blue) !important; border-color: var(--pp-blue) !important; color: var(--pp-on-blue) !important; } /* ── Buttons ────────────────────────────────────────────────────────────── */ .pp-primary, .pp-primary button { background: var(--pp-blue) !important; border: none !important; color: var(--pp-on-blue) !important; font-weight: 600 !important; border-radius: var(--pp-r-pill) !important; box-shadow: var(--pp-shadow-sm) !important; } .pp-primary:hover, .pp-primary button:hover { background: var(--pp-blue-hover) !important; } .pp-ghost button, button.pp-ghost { background: transparent !important; border: 1px solid var(--pp-btn-border) !important; color: var(--pp-ghost-fg) !important; border-radius: var(--pp-r-pill) !important; font-weight: 500 !important; } .pp-ghost button:hover, button.pp-ghost:hover { border-color: var(--pp-blue) !important; color: var(--pp-blue) !important; } /* Label colour comes from --pp-ghost-fg so it flips with the theme without needing a `.dark` selector (which Gradio would leave un-prefixed and therefore less specific than its own rewritten base rule). */ .pp-ghost button:hover, button.pp-ghost:hover { border-color: var(--pp-blue) !important; } /* ── Result stack ───────────────────────────────────────────────────────── */ /* Was a "timeline": 8px dots plus a connector column, which implied chronology. Nothing here is chronological -- these are four facets of ONE verdict. Dots removed; rhythm carried by a single gap. */ .pp-timeline { display: flex; flex-direction: column; gap: var(--pp-s3); } .pp-node { display: block; } .pp-node-last { margin-top: 0; } .pp-dot { display: none; } .pp-bubble { align-self: flex-start; max-width: 100%; background: var(--pp-card-2); border: none; border-radius: var(--pp-r); padding: 12px 16px; } .pp-bubble-meta { font-size: var(--pp-fs-1); color: var(--pp-text-muted); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; } .pp-bubble p { margin: 5px 0 0; font-size: var(--pp-fs-2); line-height: 1.6; color: var(--pp-text-dim); } .pp-card { flex: 1; min-width: 0; background: var(--pp-card); border: none; border-radius: var(--pp-r); padding: 18px 20px; box-shadow: var(--pp-shadow); } /* The priority bar earns its place on the triage card ONLY. Repeating it on all four cards is what made the accent stop meaning anything. */ .pp-card:has(.pp-label-urgency) { border-inline-start: 3px solid var(--u-fg, var(--pp-border)); } /* ── Section labels: one colour ─────────────────────────────────────────── */ /* These were three arbitrary hues (purple / pink / priority). The rule that priority is the only polychrome thing in the product has to apply here too, or the priority colour competes with decoration. */ .pp-card-label { font-size: var(--pp-fs-1); font-weight: 700; margin: 0 0 var(--pp-s3); text-transform: uppercase; letter-spacing: .09em; color: var(--pp-text-muted); } /* Section labels are colour-coded per section by request, so the four cards are distinguishable at a glance. These are small uppercase labels, not large surfaces, so they don't compete with the priority accent on the verdict line. Both themes are tuned separately -- the dark values would drop below 4.5:1 on a light ground. */ .pp-label-urgency { color: var(--u-fg); } .pp-label-purple { color: var(--pp-label-purple); } .pp-label-pink { color: var(--pp-label-pink); } /* ── The verdict: the one answer the product exists to produce ──────────── */ .pp-verdict { font-size: var(--pp-fs-5); font-weight: 700; line-height: 1.15; letter-spacing: -.02em; color: var(--u-fg); margin: 0 0 var(--pp-s1); } .pp-due { font-size: var(--pp-fs-2); color: var(--pp-text-dim); margin: 0 0 var(--pp-s3); font-variant-numeric: tabular-nums; } .pp-meta-line { font-size: var(--pp-fs-2); color: var(--pp-text-dim); margin: var(--pp-s2) 0 0; } .pp-escalated { font-size: var(--pp-fs-2); font-weight: 600; color: var(--u-fg); background: var(--u-bg); border: none; border-radius: var(--pp-r-sm); padding: 6px 11px; margin: 0 0 var(--pp-s3); display: inline-block; } /* A disagreement is information, not an alarm. */ .pp-disagreed { font-size: var(--pp-fs-2); color: var(--pp-text-dim); background: var(--pp-card-2); border: none; border-radius: var(--pp-r-sm); padding: 6px 11px; margin: 0 0 var(--pp-s3); display: inline-block; } /* ── Urgency accent: scoped custom props, light + dark ──────────────────── */ .pp-urgency-p1 { --u-bg: #ffe9eb; --u-fg: #c2283a; --u-border: #ffc4ca; } .pp-urgency-p2 { --u-bg: #fff4e0; --u-fg: #8a5a06; --u-border: #ffdda0; } .pp-urgency-p3 { --u-bg: #e7f1ff; --u-fg: #1a5fa8; --u-border: #bcdafc; } .pp-urgency-p4 { --u-bg: #eef1f6; --u-fg: #4d5a72; --u-border: #d5dbe6; } .dark .pp-urgency-p1 { --u-bg: #34161e; --u-fg: var(--pri-p1); --u-border: #5a2431; } .dark .pp-urgency-p2 { --u-bg: #332512; --u-fg: var(--pri-p2); --u-border: #5a411a; } .dark .pp-urgency-p3 { --u-bg: #142438; --u-fg: var(--pri-p3); --u-border: #1e3d5e; } .dark .pp-urgency-p4 { --u-bg: #1c2333; --u-fg: var(--pri-p4); --u-border: #33405c; } .pp-dot-urgency, .pp-dot-purple, .pp-dot-pink { background: var(--u-fg); } .pp-code-urgency { color: var(--u-fg); font-weight: 600; } .pp-badge-urgency { background: var(--u-bg); color: var(--u-fg); border: none; } .pp-badge { display: inline-block; font-size: var(--pp-fs-2); font-weight: 600; padding: 4px 11px; border-radius: var(--pp-r-pill); margin-right: var(--pp-s2); margin-bottom: var(--pp-s1); } /* ── Similar tickets: hairlines are the ONLY surviving outline ──────────── */ .pp-sim-row { font-size: var(--pp-fs-2); padding: 7px 0; border-top: 1px solid var(--pp-border); } .pp-sim-row:first-child { border-top: none; } .pp-sim-row > summary { display: flex; gap: var(--pp-s3); align-items: baseline; cursor: pointer; list-style: none; } .pp-sim-row > summary::-webkit-details-marker { display: none; } .pp-sim-row > summary::after { content: "▸"; color: var(--pp-text-muted); flex-shrink: 0; transition: transform .15s; } .pp-sim-row[open] > summary::after { transform: rotate(90deg); } .pp-sim-row > summary:hover .pp-sim-text { color: var(--pp-blue); } .pp-sim-full { margin-top: var(--pp-s2); padding: 12px 14px; background: var(--pp-card-2); border: none; border-radius: var(--pp-r-sm); } .pp-sim-body { margin: 0 0 7px; font-size: var(--pp-fs-2); line-height: 1.6; color: var(--pp-text); white-space: pre-wrap; } .pp-sim-res { margin: 0; font-size: var(--pp-fs-1); color: var(--pp-text-muted); } .pp-sim-id { color: var(--pp-text-muted); min-width: 66px; flex-shrink: 0; font-variant-numeric: tabular-nums; } .pp-sim-text { flex: 1; min-width: 0; color: var(--pp-text-dim); } .pp-sim-score { color: var(--pp-sim-score); min-width: 42px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; font-weight: 600; } /* ── Signal readout: both models, side by side ──────────────────────────── */ .pp-signals { display: grid; gap: var(--pp-s1); margin: var(--pp-s3) 0 0; padding: 11px 13px; background: var(--pp-card-2); border: none; border-radius: var(--pp-r-sm); } .pp-signal { display: flex; gap: var(--pp-s2); align-items: baseline; font-size: var(--pp-fs-2); } .pp-signal-ico { flex-shrink: 0; } .pp-signal-name { color: var(--pp-text-muted); flex: 1; min-width: 0; } .pp-signal-val { color: var(--pp-text); font-weight: 600; flex-shrink: 0; } /* ── The whole results column reads at full strength in both themes ─────── */ /* Everything here was tiered across dim/muted greys, leaving most of the actual content noticeably fainter than the work-order text beside it. --pp-strong is white in dark mode and near-black in light, so one rule covers both. The per-section labels, the verdict and the priority accents deliberately keep their own colours -- those encode meaning, this is body copy. */ .pp-bubble p, .pp-bubble-meta, .pp-meta-line, .pp-disagreed, .pp-sim-text, .pp-sim-id, .pp-sim-body, .pp-sim-res, .pp-signal-name, .pp-contractor, .pp-wo-lead, .pp-wo-context, .pp-step span, .pp-ghost-hint { color: var(--pp-strong) !important; } .pp-empty { color: var(--pp-text-muted); } .pp-empty-state { color: var(--pp-text-muted); font-size: var(--pp-fs-2); padding: var(--pp-s6) var(--pp-s1); } .pp-pre { white-space: pre-wrap; font-family: inherit; font-size: var(--pp-fs-2); line-height: 1.65; margin: 0; color: var(--pp-text); } .pp-contractor { font-size: var(--pp-fs-2); color: var(--pp-text-dim); margin-top: var(--pp-s3); padding-top: var(--pp-s3); border-top: 1px solid var(--pp-border); } .pp-fiverr-btn { display: inline-block; background: var(--pp-blue-soft); color: var(--pp-blue) !important; border: 1px solid transparent; border-radius: var(--pp-r-pill); padding: 6px 16px; font-size: var(--pp-fs-2); font-weight: 600; text-decoration: none !important; transition: background .15s, color .15s; } .pp-fiverr-btn:hover { background: var(--pp-blue); color: var(--pp-on-blue) !important; } /* ── DataFrame: native wrappers are transparent now, so declare it here ─── */ .table-wrap, .gradio-dataframe .table-wrap { background: var(--pp-card) !important; border: 1px solid var(--pp-border) !important; border-radius: var(--pp-r) !important; box-shadow: var(--pp-shadow-sm) !important; overflow: hidden; } .gradio-container table thead th { background: var(--pp-card-2) !important; color: var(--pp-text-dim) !important; font-size: var(--pp-fs-1) !important; text-transform: uppercase; letter-spacing: .07em; font-weight: 600 !important; } .gradio-container table td { font-size: var(--pp-fs-2) !important; color: var(--pp-text-dim) !important; } /* ── Accent runs through everything interactive ─────────────────────────── */ .tab-nav button.selected, button.selected { border-bottom-color: var(--pp-blue) !important; color: var(--pp-blue) !important; } a { color: var(--pp-blue); } :focus-visible { outline: 2px solid var(--pp-blue); outline-offset: 2px; } ::selection { background: var(--pp-blue); color: var(--pp-on-blue); } .pp-colophon { font-size: var(--pp-fs-1); color: var(--pp-text-muted); text-align: center; padding: var(--pp-s5) 0 var(--pp-s1); } /* Was 11px outlined -- the weakest possible treatment for the two things an operator actually wants to grab. */ .pp-copy { margin-top: var(--pp-s3); font-size: var(--pp-fs-2); font-weight: 600; font-family: inherit; background: var(--pp-card-2); color: var(--pp-text-dim); border: none; border-radius: var(--pp-r-pill); padding: 6px 15px; cursor: pointer; transition: background .15s, color .15s; } .pp-copy:hover { background: var(--pp-blue); color: var(--pp-on-blue); } /* ── Native Gradio wrappers: the outermost of the nested boxes ───────────── */ /* Gradio groups consecutive inputs into
and wraps each component in .block; BOTH paint a fill and an outline. That is what still boxed "Describe the Issue" and the Building/Unit row after .pp-panel was flattened. The input surface itself is the only thing that needs to show. */ .gradio-container .form, .gradio-container .block { background: transparent !important; border: none !important; box-shadow: none !important; } .gradio-container .form { gap: var(--pp-s3) !important; } .gradio-container textarea, .gradio-container input[type="text"], .gradio-container input[type="number"] { background: var(--pp-card-2) !important; /* Light mode surfaces sit close in value, so the input needs a hairline to read as an input at all; in dark the fill alone is enough. */ border: 1px solid var(--pp-border) !important; border-radius: var(--pp-r-sm) !important; font-size: var(--pp-fs-2) !important; color: var(--pp-text) !important; transition: border-color .15s; } .gradio-container textarea:focus, .gradio-container input[type="text"]:focus { border-color: var(--pp-blue) !important; } .gradio-container .block > label > span { font-size: var(--pp-fs-1) !important; font-weight: 600 !important; letter-spacing: .05em; text-transform: uppercase; color: var(--pp-text-muted) !important; } /* Markdown "---" separators were painted at full strength. */ .gradio-container hr { border: none; border-top: 1px solid var(--pp-border); margin: var(--pp-s5) 0 var(--pp-s4); } /* ── Verdict header ─────────────────────────────────────────────────────── */ .pp-verdict-row { display: flex; align-items: center; gap: var(--pp-s3); flex-wrap: wrap; margin: 0 0 var(--pp-s3); } .pp-verdict-ico { font-size: 26px; line-height: 1; } .pp-verdict-row .pp-verdict { margin: 0; } /* The deadline is the thing an operator acts on -- it was a grey subtitle. */ .pp-sla-pill { font-size: var(--pp-fs-2); font-weight: 600; color: var(--u-fg); background: var(--u-bg); border-radius: var(--pp-r-pill); padding: 5px 13px; font-variant-numeric: tabular-nums; white-space: nowrap; } /* ── Work order: rendered as structure, not as a wall of markdown ───────── */ .pp-wo-lead { margin: 0 0 var(--pp-s4); font-size: var(--pp-fs-3); line-height: 1.5; color: var(--pp-text); } .pp-wo-sub { font-size: var(--pp-fs-1); font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--pp-text-muted); margin: 0 0 var(--pp-s2); } .pp-steps { list-style: none; margin: 0 0 var(--pp-s4); padding: 0; } .pp-step { display: flex; gap: var(--pp-s3); align-items: flex-start; padding: 9px 0; border-top: 1px solid var(--pp-border); font-size: var(--pp-fs-2); line-height: 1.55; color: var(--pp-text-dim); } .pp-step:first-child { border-top: none; } .pp-step input[type="checkbox"] { appearance: none; -webkit-appearance: none; width: 16px; height: 16px; flex-shrink: 0; margin: 2px 0 0; border: 1.5px solid var(--pp-btn-border); border-radius: 5px; background: var(--pp-card-2); cursor: pointer; transition: background .15s, border-color .15s; } .pp-step input[type="checkbox"]:checked { background: var(--pp-blue); border-color: var(--pp-blue); } .pp-step input[type="checkbox"]:checked + span { color: var(--pp-text-muted); text-decoration: line-through; } .pp-wo-context { margin: 0; padding: 12px 14px; background: var(--pp-card-2); border-radius: var(--pp-r-sm); font-size: var(--pp-fs-2); line-height: 1.6; color: var(--pp-text-dim); } /* ── Empty state: show the SHAPE of the answer before there is any ──────── */ /* Was one line of dim text in a 90px box beside a fully populated column -- over half the screen dead on arrival, and nothing telling a first-time visitor what they were about to get. */ .pp-ghosts { display: flex; flex-direction: column; gap: var(--pp-s3); } /* The 55% opacity here dimmed the whole card INCLUDING its label, which is why the section headings read as washed out no matter what colour they were given. The placeholder BARS should look unfilled; the labels are real information about what is coming, so they stay at full strength. */ .pp-ghost-card { background: var(--pp-card); border-radius: var(--pp-r); padding: 18px 20px; box-shadow: var(--pp-shadow-sm); } .pp-ghost-label { font-size: var(--pp-fs-1); font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--pp-text-muted); margin: 0 0 var(--pp-s3); } /* The skeleton carries the same per-section colours as the real cards, so the empty state previews the layout it will become. Triage needs a fixed value here: --u-fg only exists once a priority has been decided. */ .pp-ghost-label.pp-label-purple { color: var(--pp-label-purple); } .pp-ghost-label.pp-label-pink { color: var(--pp-label-pink); } /* --pri-p3 is tuned for a dark ground and measured 2.52:1 on white, which is what made the empty-state TRIAGE label look washed out in day mode. */ .pp-label-urgency-static { color: var(--pp-label-triage); } .pp-ghost-line { height: 9px; border-radius: var(--pp-r-pill); background: var(--pp-card-2); margin-bottom: var(--pp-s2); opacity: .55; } .pp-ghost-line.w40 { width: 40%; } .pp-ghost-line.w60 { width: 60%; } .pp-ghost-line.w85 { width: 85%; } .pp-ghost-line.tall { height: 22px; width: 52%; margin-bottom: var(--pp-s3); } .pp-ghost-hint { font-size: var(--pp-fs-2); color: var(--pp-text-muted); text-align: center; padding: var(--pp-s4) 0 var(--pp-s2); margin: 0; } /* ── Responsive ─────────────────────────────────────────────────────────── */ /* The sheet had no @media rules at all, so the 2/3 column split held all the way down: on a phone the input panel and the results column were each ~40% of the viewport. Two breakpoints -- one to stop competing for horizontal room, one to loosen the dense meta rows. */ @media (max-width: 900px) { .pp-main-row { flex-direction: column !important; flex-wrap: nowrap !important; } /* The direct children ARE the two panels; targeting them positionally avoids depending on Gradio's internal column class name. */ .pp-main-row > * { width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; } .pp-hero { padding: 16px 18px; } .pp-card { padding: 16px 16px; } } @media (max-width: 600px) { .pp-hero { padding: 14px 15px; } .pp-hero h1 { font-size: var(--pp-fs-3); gap: 8px; } .pp-hero p { font-size: var(--pp-fs-1); } .pp-card { padding: 14px 13px; } .pp-verdict { font-size: var(--pp-fs-4) !important; } /* The label can't shrink (nowrap), so shrink the type instead of letting the row overflow. */ .pp-quick-row button { font-size: 11px !important; padding: 4px 7px !important; } /* Fixed gutters for the id and score columns eat most of a 360px row. */ .pp-sim-id { min-width: 0; } .pp-sim-score { min-width: 0; } /* Six columns will not fit; let the grid scroll instead of squeezing "Complaint" down to a couple of characters. */ .table-wrap, .gradio-dataframe .table-wrap { overflow-x: auto !important; } /* Action buttons go full width rather than sitting as two half-pills. */ .pp-primary, .pp-primary button, .pp-ghost button, button.pp-ghost { width: 100% !important; } } """ # ── UI ───────────────────────────────────────────────────────────────────────── QUICK_LABELS = sorted(QUICK_STARTERS_CACHE.keys()) if QUICK_STARTERS_CACHE else list(CATEGORY_ICONS.keys()) with gr.Blocks( title="PropertyPilot — AI Maintenance Assistant", css=custom_css, fill_width=True, # Dark is the intended default, but it was applied by a demo.load JS # callback -- i.e. AFTER first paint -- so every page load flashed light # and then snapped dark. In it lands before the body renders. head="", ) as demo: result_state = gr.State(None) suggestions_state = gr.State([]) gr.HTML(f"""

PropertyPilot — AI Maintenance Assistant

Paste a tenant maintenance message and get instant triage, similar past tickets, a contractor work order, a tone-matched tenant reply, and a Fiverr contractor search link.

🗂️ {len(df):,} past tickets 🎯 97% triage accuracy 🗽 Live NYC-311 feed
""") with gr.Row(): gr.Markdown("") theme_toggle = gr.Button("☀️ Day Mode", size="sm", variant="secondary", scale=0, elem_id="theme-toggle") with gr.Tabs(): # ── Process Ticket tab ──────────────────────────────────────────────── with gr.Tab("🎫 Process Ticket"): # Named so the stylesheet can stack the two columns on narrow # screens -- a Gradio Row is a flex row that never wraps on its # own, so without this the 2/3 split survives down to phone width. with gr.Row(elem_classes=["pp-main-row"]): # Left panel — inputs with gr.Column(scale=2, elem_classes=["pp-panel"]): gr.Markdown("**⚡ Quick Starters** *(instant, cached — no waiting)*") qs_btns = [] with gr.Row(elem_classes=["pp-quick-row"]): for cat in QUICK_LABELS[:5]: lbl = f"{CATEGORY_ICONS.get(cat, '🔧')} {cat}" qs_btns.append((cat, gr.Button(lbl, size="sm", elem_id=f"qs-{cat}"))) with gr.Row(elem_classes=["pp-quick-row"]): for cat in QUICK_LABELS[5:]: lbl = f"{CATEGORY_ICONS.get(cat, '🔧')} {cat}" qs_btns.append((cat, gr.Button(lbl, size="sm", elem_id=f"qs-{cat}"))) msg_box = gr.Textbox( label="📝 Describe the Issue", placeholder="Paste the tenant's maintenance message here… (start typing for suggestions)", lines=5, ) # Autocomplete suggestion buttons suggestion_btns = [] with gr.Row(): for _ in range(N_SUGGESTIONS): suggestion_btns.append(gr.Button("", visible=False, size="sm")) # Set-once metadata. It was permanently expanded at the # same visual weight as the message box it supports. with gr.Accordion("🏠 Building & unit", open=False): with gr.Row(): bldg_box = gr.Textbox(label="Building ID", value="B-01") unit_box = gr.Textbox(label="Unit", value="1A") with gr.Row(): analyze_btn = gr.Button("✨ Analyze & Generate", elem_classes=["pp-primary"]) clear_btn = gr.Button("Clear", elem_classes=["pp-ghost"]) status_md = gr.Markdown("") gr.Markdown("---\n**📡 Ops Dispatch**") slack_btn = gr.Button("📤 Send to Slack Ops Channel", elem_classes=["pp-ghost"]) slack_md = gr.Markdown("") # Right panel — timeline output with gr.Column(scale=3, elem_classes=["pp-panel"]): triage_label = gr.Markdown("") timeline_html = gr.HTML(_EMPTY_TIMELINE) # Kept as hidden components rather than deleted: they're # still wired as outputs everywhere, and the visible # accordion only ever re-showed text already rendered in # the cards above. Copy now lives on each card instead. work_order_box = gr.Textbox(visible=False) reply_box = gr.Textbox(visible=False) # Wire analyze analyze_btn.click( process, inputs=[msg_box, bldg_box, unit_box], outputs=[timeline_html, work_order_box, reply_box, triage_label, status_md, result_state], # The generator now paints its own skeleton and status line, so # Gradio's overlay + "0.0s" counter would sit on top of the very # thing it is meant to stand in for. show_progress="hidden", ) # Wire clear clear_btn.click( clear_all, outputs=[msg_box, bldg_box, unit_box, timeline_html, work_order_box, reply_box, triage_label, status_md, result_state], ) # Wire Slack slack_btn.click(send_to_slack, inputs=[result_state], outputs=[slack_md]) # Wire Quick Starters for cat, btn in qs_btns: btn.click( lambda c=cat: use_cached_quick_starter(c), outputs=[msg_box, bldg_box, unit_box, timeline_html, work_order_box, reply_box, triage_label, status_md, result_state], ) # Cosmetic only -- visually marks which Quick Starter was last # clicked, doesn't touch the cached-result logic above. btn.click(fn=None, js=f""" () => {{ document.querySelectorAll('.pp-quick-row button').forEach(b => b.classList.remove('pp-qs-selected')); document.getElementById('qs-{cat}').classList.add('pp-qs-selected'); }} """) # Wire autocomplete msg_box.change( get_suggestions, inputs=[msg_box], outputs=suggestion_btns + [suggestions_state], ) for i, sb in enumerate(suggestion_btns): sb.click(_make_fill_fn(i), inputs=[suggestions_state], outputs=[msg_box]) # Theme toggle -- previously a Checkbox wired to toggle .dark only # on .gradio-container, which did nothing because :root and .dark # set the SAME values (see custom_css). Now :root is the light # default and .dark is a real override, so this toggle needs to # flip .dark on every likely scoping root (Gradio's own built-in # component theme may key off / rather than the # container div) and flip its own label to reflect the new state. theme_toggle.click( fn=None, js=""" () => { document.body.classList.toggle('dark'); document.documentElement.classList.toggle('dark'); const isDark = document.body.classList.contains('dark'); document.querySelectorAll('.gradio-container').forEach( el => el.classList.toggle('dark', isDark) ); const btn = document.getElementById('theme-toggle'); if (btn) btn.textContent = isDark ? '☀️ Day Mode' : '🌙 Night Mode'; } """, ) # ── NYC-311 Live Feed tab ───────────────────────────────────────────── with gr.Tab("🗽 NYC-311 Live Feed"): gr.Markdown( "#### Real-Time Maintenance Complaints — New York City\n" "Live data from NYC Open Data (no API key required). " "Filtered to building maintenance categories. " "Pick a row and run it through the full pipeline." ) nyc_refresh = gr.Button("🔄 Refresh Feed", variant="secondary") nyc_status = gr.Markdown("") # The select hint used to sit BELOW the table, so you could not see # it until you had already scrolled past the thing it describes. selected_md = gr.Markdown("_Click any row in the table below to select it._") nyc_table = gr.DataFrame( interactive=False, headers=["Reported", "Borough", "Complaint", "Detail", "Status", "Last Updated"], max_height=380, ) # Holds the exact rows currently displayed in the table above, so # "analyze row N" resolves against what the user is actually # looking at. Previously this re-fetched the live API on click -- # the feed is ordered newest-first, so any complaint filed in # between shifted every index down and you'd silently analyze a # different row than the one you picked. nyc_rows_state = gr.State([]) row_index = gr.Number(value=-1, precision=0, visible=False) process_311_btn = gr.Button("✨ Analyze selected complaint", elem_classes=["pp-primary"]) triage_label_311 = gr.Markdown("") timeline_311 = gr.HTML(_EMPTY_TIMELINE_311) work_order_311 = gr.Textbox(visible=False) reply_311 = gr.Textbox(visible=False) status_311 = gr.Markdown("") state_311 = gr.State(None) nyc_refresh.click(fn=fetch_nyc311, outputs=[nyc_table, nyc_status, nyc_rows_state]) # Populate on arrival instead of showing an empty grid until the # user thinks to hit Refresh. demo.load(fn=fetch_nyc311, outputs=[nyc_table, nyc_status, nyc_rows_state]) nyc_table.select(fn=on_311_select, inputs=[nyc_rows_state], outputs=[row_index, selected_md]) process_311_btn.click( process_311_row, inputs=[row_index, nyc_rows_state], outputs=[timeline_311, work_order_311, reply_311, triage_label_311, status_311, state_311], show_progress="hidden", ) gr.HTML( '

' 'FAISS retrieval over 13,725 tickets · DistilBERT triage classifier ' '(97.5% / 76.6% macro-F1) · QLoRA-finetuned Qwen2.5-1.5B generation' '

' ) # Dark mode by default on page load (matches the previous default look). demo.load( fn=None, js=""" () => { document.body.classList.add('dark'); document.documentElement.classList.add('dark'); document.querySelectorAll('.gradio-container').forEach(el => el.classList.add('dark')); } """, ) demo.launch()