Spaces:
Running
Running
| """ | |
| 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 | |
| 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 & 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 " | |
| "“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 = ( | |
| '<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 = "▲" if is_escalation else "⚠" | |
| 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> Β· | |
| 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 · 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 · 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() | |