0tizm0's picture
Remove debug prints - Slack auto-routing confirmed working
ea530be verified
Raw
History Blame Contribute Delete
107 kB
"""
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"(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)")
# A trailing sign-off line that is *only* a bracket placeholder ("[Your Name]")
# is never legitimate content.
_PLACEHOLDER_LINE = re.compile(r"^\s*\[[^\]]{2,40}\]\s*$", re.MULTILINE)
def _clean(text):
"""Strip the duplicate second round plus the markdown artifacts the model
leaves in. These render in a plain <pre>, 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 = (
"<the report is brief and this is an emergency: FIRST confirm in one "
"sentence that help is being dispatched immediately, THEN ask one short "
f"question to pin down {joined}. Do not delay the dispatch on an answer.>"
)
else:
reply_spec = (
"<the report is too brief to act on: acknowledge it warmly in one "
f"sentence, then ask one specific question about {joined}. "
"Ask ONE question only -- do not invent details the tenant never gave.>"
)
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"<professional work order: issue description, then a line reading exactly "
f"\"Priority: {urgency} | SLA: {sla}\" verbatim, then 2-3 action steps>\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 (
'<div class="pp-ghosts">'
'<div class="pp-ghost-card">'
'<p class="pp-ghost-label pp-label-urgency-static">Triage</p>'
'<div class="pp-ghost-line tall"></div>'
'<div class="pp-ghost-line w40"></div>'
'</div>'
'<div class="pp-ghost-card">'
'<p class="pp-ghost-label pp-label-purple">Similar past tickets</p>'
'<div class="pp-ghost-line w85"></div>'
'<div class="pp-ghost-line w60"></div>'
'<div class="pp-ghost-line w85"></div>'
'</div>'
'<div class="pp-ghost-card">'
'<p class="pp-ghost-label pp-label-pink">Work order &amp; tenant reply</p>'
'<div class="pp-ghost-line w85"></div>'
'<div class="pp-ghost-line w60"></div>'
'</div>'
'<p class="pp-ghost-hint">' + hint + '</p>'
'</div>'
)
_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 "
"&ldquo;Analyze selected complaint&rdquo; 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 = (
'<div class="pp-signals">'
+ "".join(
f'<div class="pp-signal">'
f'<span class="pp-signal-ico">{ico}</span>'
f'<span class="pp-signal-name">{name}</span>'
f'<span class="pp-signal-val">{val}</span>'
f'</div>'
for ico, name, val in rows
)
+ "</div>"
)
# 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'<details class="pp-sim-row">'
f'<summary>'
f'<span class="pp-sim-id">{esc(s["ticket_id"])}</span>'
f'<span class="pp-sim-text">{esc(s["raw_text"][:110])}'
f'{"…" if len(s["raw_text"]) > 110 else ""}</span>'
f'<span class="pp-sim-score">{s["similarity"]:.0%}</span>'
f'</summary>'
f'<div class="pp-sim-full">'
f'<p class="pp-sim-body">{esc(s["raw_text"])}</p>'
f'<p class="pp-sim-res">'
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'</p>'
f'</div>'
f'</details>'
for s in similar
) or '<div class="pp-sim-row pp-empty">No close matches found.</div>'
contractor_html = ""
if contractor:
contractor_html = (
f'<div class="pp-contractor">'
f'πŸ—οΈ Recommended: <b>{esc(contractor["contractor_id"])}</b> '
f'({esc(contractor["specialty"])}) β€” {esc(contractor["reason"])}'
f'</div>'
)
# 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 = "&#9650;" if is_escalation else "&#9888;"
escalation_html = f'<p class="{cls}">{mark} {esc(reason)}</p>'
flink = fiverr_url(category)
fiverr_html = (
f'<a href="{esc(flink)}" target="_blank" rel="noopener" class="pp-fiverr-btn">'
f'πŸ”— Find a {esc(category)} contractor on Fiverr</a>'
)
return f"""
<div class="pp-timeline {urgency_class}">
<div class="pp-bubble">
<span class="pp-bubble-meta">{esc(building)} / {esc(unit)}</span>
<p>{esc(text)}</p>
</div>
<div class="pp-node">
<span class="pp-dot pp-dot-urgency"></span>
<div class="pp-card">
<p class="pp-card-label pp-label-urgency">Triage</p>
<div class="pp-verdict-row">
<span class="pp-verdict-ico">{icon}</span>
<h2 class="pp-verdict">{esc(category)} Β· {code}</h2>
<span class="pp-sla-pill">{esc(_deadline_label(code))}</span>
</div>
{escalation_html}
<p class="pp-meta-line">
Tone: <code class="pp-code-urgency">{esc(tone)}</code> &nbsp;Β·&nbsp;
Confidence: {conf_label}
</p>
{cross_check_html}
<div style="margin-top:12px">{fiverr_html}</div>
</div>
</div>
<div class="pp-node">
<span class="pp-dot pp-dot-purple"></span>
<div class="pp-card">
<p class="pp-card-label pp-label-purple">Similar past tickets</p>
{sim_rows}
{contractor_html}
</div>
</div>
</div>
"""
# 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 <pre> 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 <pre> 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 '<p class="pp-empty">No work order generated.</p>'
# 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'<pre class="pp-pre">{esc(raw)}</pre>'
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'<p class="pp-wo-lead">{esc(" ".join(lead))}</p>')
if steps:
out.append('<p class="pp-wo-sub">Actions</p><ul class="pp-steps">')
out += [
f'<li class="pp-step"><input type="checkbox"><span>{esc(s)}</span></li>'
for s in steps
]
out.append("</ul>")
if context:
out.append('<p class="pp-wo-sub">From the closest past case</p>')
out.append(f'<p class="pp-wo-context">{esc(" ".join(context))}</p>')
return "".join(out) or f'<pre class="pp-pre">{esc(raw)}</pre>'
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"""
<div class="pp-timeline {urgency_class}" style="margin-top:16px">
<div class="pp-node">
<span class="pp-dot pp-dot-pink"></span>
<div class="pp-card">
<p class="pp-card-label pp-label-pink">Work order &middot; for the contractor</p>
{_format_work_order(work_order)}
<button class="pp-copy" type="button"
onclick="navigator.clipboard.writeText(this.previousElementSibling.innerText);this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1400)">Copy</button>
</div>
</div>
<div class="pp-node pp-node-last">
<span class="pp-dot pp-dot-pink"></span>
<div class="pp-card">
<p class="pp-card-label pp-label-pink">Tenant reply &middot; ready to send</p>
<pre class="pp-pre">{esc(reply)}</pre>
<button class="pp-copy" type="button"
onclick="navigator.clipboard.writeText(this.previousElementSibling.innerText);this.textContent='Copied';setTimeout(()=>this.textContent='Copy',1400)">Copy</button>
</div>
</div>
</div>
"""
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 (
'<div class="pp-timeline"><p class="pp-empty-state">'
"⚠️ Input doesn't resemble a maintenance request.</p></div>",
"", "", "",
"⚠️ 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 <span> 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 <div class="form"> 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 <head> it lands before the body renders.
head="<script>document.documentElement.classList.add('dark');</script>",
) as demo:
result_state = gr.State(None)
suggestions_state = gr.State([])
gr.HTML(f"""
<div class="pp-hero">
<h1>
<svg class="pp-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"
role="img" aria-label="PropertyPilot">
<path d="M3 21V7.5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1V21"/>
<path d="M1.5 21h21"/>
<path d="M6 10h1.5M6 13.5h1.5M6 17h1.5"/>
<path d="M13.5 18l3-3.5 2.5 2 3.2-4.7"/>
<path d="M19.4 11.4h2.8v2.8"/>
<path d="M16.6 3.4l.55 1.45 1.45.55-1.45.55-.55 1.45-.55-1.45-1.45-.55 1.45-.55z"
fill="currentColor" stroke="none"/>
</svg>
<span>PropertyPilot β€” AI Maintenance Assistant</span>
</h1>
<p>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.</p>
<div class="pp-badges">
<span class="pp-stat-badge">πŸ—‚οΈ {len(df):,} past tickets</span>
<span class="pp-stat-badge">🎯 97% triage accuracy</span>
<span class="pp-stat-badge">πŸ—½ Live NYC-311 feed</span>
</div>
</div>
""")
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 <html>/<body> 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(
'<p class="pp-colophon">'
'FAISS retrieval over 13,725 tickets Β· DistilBERT triage classifier '
'(97.5% / 76.6% macro-F1) Β· QLoRA-finetuned Qwen2.5-1.5B generation'
'</p>'
)
# 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()