Spaces:
Sleeping
Sleeping
Target h4 directly for the section-title bullet (Gradio nests a prose wrapper div)
Browse files
app.py
CHANGED
|
@@ -1,34 +1,41 @@
|
|
| 1 |
"""
|
| 2 |
-
PropertyPilot
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
New : Fiverr contractor search link
|
| 6 |
-
"""
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import os
|
| 9 |
import re
|
| 10 |
import json
|
| 11 |
-
import
|
|
|
|
| 12 |
from datetime import datetime
|
|
|
|
| 13 |
|
| 14 |
import torch
|
|
|
|
| 15 |
import pandas as pd
|
| 16 |
import faiss
|
| 17 |
import gradio as gr
|
| 18 |
-
import spaces
|
| 19 |
import requests
|
| 20 |
from sentence_transformers import SentenceTransformer
|
| 21 |
from huggingface_hub import hf_hub_download
|
| 22 |
-
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
#
|
| 27 |
-
|
| 28 |
-
|
| 29 |
SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")
|
| 30 |
-
|
| 31 |
-
|
| 32 |
|
| 33 |
SLA_MAP = {
|
| 34 |
"P1": "4 hours",
|
|
@@ -37,20 +44,14 @@ SLA_MAP = {
|
|
| 37 |
"P4": "7-14 business days",
|
| 38 |
}
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
"P1":
|
| 45 |
-
"P2":
|
| 46 |
-
"P3":
|
| 47 |
-
"P4":
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
CATEGORY_ICONS = {
|
| 51 |
-
"Electrical": "⚡", "Plumbing": "💧", "Hvac": "🌡️", "Appliance": "🔧",
|
| 52 |
-
"Structural": "🏗️", "Pest": "🐛", "Security": "🔒", "Noise": "🔊",
|
| 53 |
-
"Elevator": "🛗", "Other": "📋",
|
| 54 |
}
|
| 55 |
|
| 56 |
TONE_INSTRUCTIONS = {
|
|
@@ -61,7 +62,7 @@ TONE_INSTRUCTIONS = {
|
|
| 61 |
"Give a SPECIFIC date/time commitment."),
|
| 62 |
"passive-aggressive": ("The tenant is passive-aggressive. Be extra warm and proactive. "
|
| 63 |
"Avoid any defensiveness. Thank them for flagging the issue."),
|
| 64 |
-
"polite-formal": ("The tenant is polite and formal. Match their register exactly
|
| 65 |
"be professional, precise, and respectful."),
|
| 66 |
"vague-confused": ("The tenant is unsure about the problem. Be clear and patient. "
|
| 67 |
"Ask ONE specific clarifying question if the issue is ambiguous."),
|
|
@@ -69,26 +70,25 @@ TONE_INSTRUCTIONS = {
|
|
| 69 |
"Use a numbered list if helpful."),
|
| 70 |
}
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
"
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
"
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
"
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
|
| 86 |
-
|
| 87 |
-
kw = FIVERR_KEYWORDS.get(category, "handyman+repair")
|
| 88 |
-
return f"https://www.fiverr.com/search/gigs?query={kw}"
|
| 89 |
-
|
| 90 |
-
# ── Load data + models at startup ─────────────────────────────────────────────
|
| 91 |
print("Downloading files from HF Hub...")
|
|
|
|
| 92 |
csv_path = hf_hub_download(repo_id=HF_REPO, filename="propertypilot_tickets.csv",
|
| 93 |
repo_type="dataset", token=HF_TOKEN)
|
| 94 |
faiss_path = hf_hub_download(repo_id=HF_REPO, filename="recommender/index.faiss",
|
|
@@ -101,6 +101,23 @@ df.reset_index(drop=True, inplace=True)
|
|
| 101 |
df["urgency_code"] = df["urgency"].str[:2]
|
| 102 |
print(f"Dataset: {len(df):,} rows")
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
with open(config_path) as f:
|
| 105 |
cfg = json.load(f)
|
| 106 |
|
|
@@ -109,17 +126,76 @@ index = faiss.read_index(faiss_path)
|
|
| 109 |
embed_model = SentenceTransformer(cfg["model_name"])
|
| 110 |
print(f"Embedder: {cfg['model_name']} | MIN_SIM={MIN_SIM} | {index.ntotal:,} vectors")
|
| 111 |
|
| 112 |
-
print(f"Loading
|
| 113 |
-
gen_tokenizer = AutoTokenizer.from_pretrained(
|
| 114 |
-
gen_model = AutoModelForCausalLM.from_pretrained(GEN_MODEL, torch_dtype=torch.
|
| 115 |
-
gen_model = PeftModel.from_pretrained(gen_model, LORA_REPO, token=HF_TOKEN)
|
| 116 |
gen_model.eval()
|
| 117 |
-
print("Finetuned generation model ready.")
|
| 118 |
|
| 119 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
QUICK_STARTERS_CACHE = {}
|
| 121 |
AUTOCOMPLETE_PHRASES = []
|
| 122 |
-
TEXT_TO_CACHED = {}
|
| 123 |
try:
|
| 124 |
qs_path = hf_hub_download(repo_id=HF_REPO, filename="recommender/quick_starters_v2.json",
|
| 125 |
repo_type="dataset", token=HF_TOKEN)
|
|
@@ -127,17 +203,26 @@ try:
|
|
| 127 |
_qs_data = json.load(f)
|
| 128 |
QUICK_STARTERS_CACHE = _qs_data.get("quick_starters", {})
|
| 129 |
AUTOCOMPLETE_PHRASES = _qs_data.get("autocomplete_phrases", [])
|
| 130 |
-
|
| 131 |
-
print(f"Loaded {len(QUICK_STARTERS_CACHE)} Quick Starters, "
|
| 132 |
f"{len(AUTOCOMPLETE_PHRASES)} autocomplete phrases.")
|
| 133 |
except Exception as e:
|
| 134 |
-
print(f"Could not load quick_starters_v2.json ({e}) — Quick Starters disabled.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
|
|
|
| 137 |
def _encode(text):
|
| 138 |
-
prefix = cfg.get("query_prefix", "")
|
| 139 |
return embed_model.encode(
|
| 140 |
-
[
|
| 141 |
normalize_embeddings=True,
|
| 142 |
convert_to_numpy=True,
|
| 143 |
).astype("float32")
|
|
@@ -146,12 +231,16 @@ def _encode(text):
|
|
| 146 |
def recommend_similar(query_text, top_k=3, building_id=None):
|
| 147 |
qvec = _encode(query_text)
|
| 148 |
sims_r, idxs_r = index.search(qvec, top_k * 3 + 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
cand = [(float(s), int(i)) for s, i in zip(sims_r[0], idxs_r[0])
|
| 150 |
-
if
|
| 151 |
if not cand:
|
| 152 |
return {"status": "no_match", "confidence": "low",
|
| 153 |
"message": "No similar ticket found above the similarity threshold.",
|
| 154 |
-
"similar_tickets": [], "contractor_rank": []}
|
| 155 |
if building_id:
|
| 156 |
same = [c for c in cand if df.iloc[c[1]]["building_id"] == building_id]
|
| 157 |
rest = [c for c in cand if df.iloc[c[1]]["building_id"] != building_id]
|
|
@@ -163,8 +252,8 @@ def recommend_similar(query_text, top_k=3, building_id=None):
|
|
| 163 |
for sim, i in cand:
|
| 164 |
row = df.iloc[i]
|
| 165 |
similar.append({
|
| 166 |
-
"similarity": round(
|
| 167 |
-
"ticket_id":
|
| 168 |
"category": row["category"],
|
| 169 |
"urgency": row["urgency"],
|
| 170 |
"tenant_tone": row["tenant_tone"],
|
|
@@ -173,333 +262,479 @@ def recommend_similar(query_text, top_k=3, building_id=None):
|
|
| 173 |
"cost_usd": float(row["cost_usd"]),
|
| 174 |
"contractor_id": row["contractor_id"],
|
| 175 |
"resolution_notes": row["resolution_notes"],
|
|
|
|
| 176 |
})
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
-
top_sim = cand[0][0]
|
| 190 |
-
confidence = "high" if top_sim > 0.85 else "medium" if top_sim > 0.70 else "low"
|
| 191 |
-
return {
|
| 192 |
-
"status": "ok",
|
| 193 |
-
"confidence": confidence,
|
| 194 |
-
"message": f"Top similarity: {top_sim:.1%}",
|
| 195 |
-
"similar_tickets": similar,
|
| 196 |
-
"contractor_rank": contractor_rank,
|
| 197 |
-
}
|
| 198 |
|
| 199 |
-
|
| 200 |
-
@spaces.GPU
|
| 201 |
-
def _generate(prompt, max_new_tokens=340, temperature=0.6):
|
| 202 |
try:
|
| 203 |
-
messages = [
|
| 204 |
-
{"role": "system", "content": "You are an assistant for a property management company."},
|
| 205 |
-
{"role": "user", "content": prompt},
|
| 206 |
-
]
|
| 207 |
-
text = gen_tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 208 |
-
inputs = gen_tokenizer(text, return_tensors="pt").to(gen_model.device)
|
| 209 |
with torch.no_grad():
|
| 210 |
-
|
| 211 |
-
**inputs,
|
| 212 |
-
max_new_tokens=max_new_tokens,
|
| 213 |
-
temperature=temperature,
|
| 214 |
-
do_sample=True,
|
| 215 |
-
top_p=0.9,
|
| 216 |
-
repetition_penalty=1.1,
|
| 217 |
-
pad_token_id=gen_tokenizer.eos_token_id,
|
| 218 |
-
)
|
| 219 |
-
new_tokens = out[0][inputs["input_ids"].shape[-1]:]
|
| 220 |
-
return gen_tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
|
| 221 |
except Exception as e:
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
-
_WO_HDR = re.compile(r"[\*\#\s]*work order[\*\#\s]*:[\*\#\s]*", re.IGNORECASE)
|
| 226 |
-
_TR_HDR = re.compile(r"[\*\#\s]*tenant reply[\*\#\s]*:[\*\#\s]*", re.IGNORECASE)
|
| 227 |
|
|
|
|
|
|
|
| 228 |
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
return text.strip().strip("*#").strip()
|
| 231 |
|
| 232 |
|
| 233 |
-
def
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
else:
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
| 255 |
if similar_tickets:
|
| 256 |
t = similar_tickets[0]
|
| 257 |
-
past_case = (f"
|
| 258 |
-
f"
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
prompt = (
|
| 264 |
-
"
|
|
|
|
|
|
|
|
|
|
| 265 |
"WORK ORDER:\n"
|
| 266 |
-
"<professional work order
|
|
|
|
|
|
|
|
|
|
| 267 |
"TENANT REPLY:\n"
|
| 268 |
-
"<2-4 sentence reply to tenant, matching tone
|
| 269 |
-
|
|
|
|
| 270 |
f"- Building: {building_id}, Unit: {unit}\n"
|
| 271 |
f"- Category: {category}\n"
|
| 272 |
-
f"- Priority: {urgency} (SLA: {sla})\n"
|
| 273 |
f"- Tenant report: {tenant_message}\n"
|
| 274 |
-
f"-
|
| 275 |
f"{past_case}{contractor_info}"
|
| 276 |
)
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
building_id = building_id.strip() or None
|
| 283 |
unit = unit.strip() or "N/A"
|
| 284 |
-
rec = recommend_similar(tenant_message, top_k=3, building_id=building_id)
|
| 285 |
-
similar = rec["similar_tickets"]
|
| 286 |
-
contractor = rec["contractor_rank"][0] if rec["contractor_rank"] else None
|
| 287 |
-
category = similar[0]["category"] if similar else "Unknown"
|
| 288 |
-
urgency = similar[0]["urgency"] if similar else "P3 Standard (3-5d)"
|
| 289 |
-
tone = similar[0]["tenant_tone"] if similar else "polite-formal"
|
| 290 |
-
work_order, tenant_reply = generate_ticket_response(
|
| 291 |
-
tenant_message, category, urgency, tone,
|
| 292 |
-
building_id or "Unknown", unit, similar, contractor,
|
| 293 |
-
)
|
| 294 |
-
return {
|
| 295 |
-
"triage": {"category": category, "urgency": urgency, "tone": tone},
|
| 296 |
-
"retrieval": rec,
|
| 297 |
-
"contractor": contractor,
|
| 298 |
-
"work_order": work_order,
|
| 299 |
-
"tenant_reply": tenant_reply,
|
| 300 |
-
"tenant_message": tenant_message,
|
| 301 |
-
"building_id": building_id or "Unknown",
|
| 302 |
-
"unit": unit,
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
# ── HTML timeline rendering ───────────────────────────────────────────────────
|
| 306 |
-
def esc(s) -> str:
|
| 307 |
-
return html.escape(str(s or ""))
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
_EMPTY_TIMELINE = (
|
| 311 |
-
'<div class="pp-timeline">'
|
| 312 |
-
'<p class="pp-empty-state">Paste a tenant message and hit Analyze.</p>'
|
| 313 |
-
'</div>'
|
| 314 |
-
)
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
def _render_timeline(result):
|
| 318 |
-
t = result["triage"]
|
| 319 |
-
category = t["category"]
|
| 320 |
-
urgency = t["urgency"]
|
| 321 |
-
tone = t["tone"]
|
| 322 |
-
similar = result["retrieval"]["similar_tickets"]
|
| 323 |
-
contractor = result.get("contractor")
|
| 324 |
-
building = result["building_id"]
|
| 325 |
-
unit = result["unit"]
|
| 326 |
-
text = result["tenant_message"]
|
| 327 |
-
|
| 328 |
-
code = urgency[:2]
|
| 329 |
-
bg, fg, border = URGENCY_STYLE.get(code, URGENCY_STYLE["P3"])
|
| 330 |
-
icon = CATEGORY_ICONS.get(category, "📋")
|
| 331 |
-
sla = SLA_MAP.get(code, "TBD")
|
| 332 |
-
urg_emoji = URGENCY_EMOJI.get(code, "")
|
| 333 |
-
|
| 334 |
-
conf_label = {"high": "✅ High", "medium": "⚠️ Medium", "low": "❓ Low"}.get(
|
| 335 |
-
result["retrieval"]["confidence"], result["retrieval"]["confidence"]
|
| 336 |
-
)
|
| 337 |
-
|
| 338 |
-
# Similar tickets rows
|
| 339 |
-
sim_rows = "".join(
|
| 340 |
-
f'<div class="pp-sim-row">'
|
| 341 |
-
f'<span class="pp-sim-id">{esc(s["ticket_id"])}</span>'
|
| 342 |
-
f'<span class="pp-sim-text">{esc(s["raw_text"][:110])}…</span>'
|
| 343 |
-
f'<span class="pp-sim-score">{s["similarity"]:.0%}</span>'
|
| 344 |
-
f'</div>'
|
| 345 |
-
for s in similar
|
| 346 |
-
) or '<div class="pp-sim-row pp-empty">No close matches found.</div>'
|
| 347 |
-
|
| 348 |
-
contractor_html = ""
|
| 349 |
-
if contractor:
|
| 350 |
-
contractor_html = (
|
| 351 |
-
f'<div class="pp-contractor">'
|
| 352 |
-
f'🏗️ Recommended: <b>{esc(contractor["contractor_id"])}</b> '
|
| 353 |
-
f'({esc(contractor["specialty"])}) — {esc(contractor["reason"])}'
|
| 354 |
-
f'</div>'
|
| 355 |
-
)
|
| 356 |
-
|
| 357 |
-
flink = fiverr_url(category)
|
| 358 |
-
fiverr_html = (
|
| 359 |
-
f'<a href="{esc(flink)}" target="_blank" rel="noopener" class="pp-fiverr-btn">'
|
| 360 |
-
f'🔗 Find a {esc(category)} contractor on Fiverr</a>'
|
| 361 |
-
)
|
| 362 |
-
|
| 363 |
-
return f"""
|
| 364 |
-
<div class="pp-timeline">
|
| 365 |
-
<div class="pp-bubble">
|
| 366 |
-
<span class="pp-bubble-meta">{esc(building)} / {esc(unit)}</span>
|
| 367 |
-
<p>{esc(text)}</p>
|
| 368 |
-
</div>
|
| 369 |
-
|
| 370 |
-
<div class="pp-node">
|
| 371 |
-
<span class="pp-dot" style="background:{fg}"></span>
|
| 372 |
-
<div class="pp-card">
|
| 373 |
-
<p class="pp-card-label" style="color:{fg}">Triage</p>
|
| 374 |
-
<span class="pp-badge" style="background:{bg};color:{fg};border:1px solid {border}">{icon} {esc(category)}</span>
|
| 375 |
-
<span class="pp-badge" style="background:{bg};color:{fg};border:1px solid {border}">{urg_emoji} Priority {code}</span>
|
| 376 |
-
<span class="pp-badge" style="background:{bg};color:{fg};border:1px solid {border}">SLA: {sla}</span>
|
| 377 |
-
<p style="font-size:12px;margin-top:8px;color:#9a9ba0">
|
| 378 |
-
Tone: <code style="color:{fg}">{esc(tone)}</code> ·
|
| 379 |
-
Confidence: {conf_label}
|
| 380 |
-
</p>
|
| 381 |
-
<div style="margin-top:12px">{fiverr_html}</div>
|
| 382 |
-
</div>
|
| 383 |
-
</div>
|
| 384 |
-
|
| 385 |
-
<div class="pp-node">
|
| 386 |
-
<span class="pp-dot pp-dot-purple"></span>
|
| 387 |
-
<div class="pp-card">
|
| 388 |
-
<p class="pp-card-label pp-label-purple">Similar past tickets</p>
|
| 389 |
-
{sim_rows}
|
| 390 |
-
{contractor_html}
|
| 391 |
-
</div>
|
| 392 |
-
</div>
|
| 393 |
-
</div>
|
| 394 |
-
"""
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
def _render_result_cards(work_order, reply):
|
| 398 |
-
return f"""
|
| 399 |
-
<div class="pp-node pp-node-last">
|
| 400 |
-
<span class="pp-dot pp-dot-pink"></span>
|
| 401 |
-
<div class="pp-card">
|
| 402 |
-
<p class="pp-card-label pp-label-pink">Work order</p>
|
| 403 |
-
<pre class="pp-pre">{esc(work_order)}</pre>
|
| 404 |
-
<p class="pp-card-label pp-label-pink" style="margin-top:10px">Tenant reply</p>
|
| 405 |
-
<pre class="pp-pre">{esc(reply)}</pre>
|
| 406 |
-
</div>
|
| 407 |
-
</div>
|
| 408 |
-
"""
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
def _result_to_outputs(result):
|
| 412 |
-
timeline = _render_timeline(result) + _render_result_cards(
|
| 413 |
-
result["work_order"], result["tenant_reply"]
|
| 414 |
-
)
|
| 415 |
-
triage_lbl = f"{result['triage']['category']} · {result['triage']['urgency'][:2]}"
|
| 416 |
-
result_json = json.dumps(result, default=str)
|
| 417 |
-
return timeline, result["work_order"], result["tenant_reply"], triage_lbl, result_json
|
| 418 |
-
|
| 419 |
-
# ── Cache helpers ─────────────────────────────────────────────────────────────
|
| 420 |
-
def _render_from_cache(tenant_message, building_id, unit, cached, status_msg):
|
| 421 |
rec = recommend_similar(tenant_message, top_k=3, building_id=building_id)
|
| 422 |
similar = rec["similar_tickets"]
|
| 423 |
contractor = rec["contractor_rank"][0] if rec["contractor_rank"] else None
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
"retrieval": rec,
|
| 430 |
"contractor": contractor,
|
| 431 |
-
"work_order": cached.get("work_order", ""),
|
| 432 |
-
"tenant_reply": cached.get("tenant_reply", ""),
|
| 433 |
"tenant_message": tenant_message,
|
| 434 |
-
"building_id": building_id,
|
| 435 |
"unit": unit,
|
| 436 |
}
|
| 437 |
-
tl, wo, rp, lbl, rj = _result_to_outputs(result)
|
| 438 |
-
return tl, wo, rp, lbl, status_msg, rj
|
| 439 |
|
|
|
|
|
|
|
|
|
|
| 440 |
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
return ("", "B-01", "1A", _EMPTY_TIMELINE, "", "", "", "Quick Starter not available.", None)
|
| 445 |
-
tm = cached["tenant_message"]
|
| 446 |
-
bid = cached.get("building_id", "B-01")
|
| 447 |
-
u = cached.get("unit", "1A")
|
| 448 |
-
tl, wo, rp, lbl, status, rj = _render_from_cache(tm, bid, u, cached, "Loaded from cache — instant!")
|
| 449 |
-
return tm, bid, u, tl, wo, rp, lbl, status, rj
|
| 450 |
-
|
| 451 |
-
# ── Gradio process function ───────────────────────────────────────────────────
|
| 452 |
-
def process(tenant_message, building_id, unit):
|
| 453 |
-
if not tenant_message or not tenant_message.strip():
|
| 454 |
-
return _EMPTY_TIMELINE, "", "", "", "Please enter a tenant message.", None
|
| 455 |
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
)
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
'<div class="pp-timeline"><p class="pp-empty-state">'
|
| 469 |
-
"⚠️ Input doesn't resemble a maintenance request.</p></div>",
|
| 470 |
-
"", "", "",
|
| 471 |
-
"⚠️ Not recognized as a maintenance request — please describe a specific issue.",
|
| 472 |
-
None,
|
| 473 |
)
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
-
def clear_all():
|
| 479 |
-
return ("", "B-01", "1A", _EMPTY_TIMELINE, "", "", "", "", None)
|
| 480 |
|
| 481 |
-
# ── Slack ─────────────────────────────────────────
|
| 482 |
def send_to_slack(result_json):
|
| 483 |
if not result_json:
|
| 484 |
return "Run the pipeline first."
|
| 485 |
if not SLACK_WEBHOOK:
|
| 486 |
-
return "Slack not configured — add SLACK_WEBHOOK_URL
|
| 487 |
result = json.loads(result_json) if isinstance(result_json, str) else result_json
|
| 488 |
code = result["triage"]["urgency"][:2]
|
| 489 |
c = result.get("contractor")
|
| 490 |
-
con_text
|
| 491 |
sim_lines = "\n".join(
|
| 492 |
f"[{t['similarity']:.2f}] {t['category']} — {t['raw_text'][:70]}..."
|
| 493 |
for t in result["retrieval"]["similar_tickets"][:3]
|
| 494 |
) or "None"
|
| 495 |
payload = {"attachments": [{
|
| 496 |
"color": URGENCY_HEX.get(code, "#7289DA"),
|
| 497 |
-
"title": f"PropertyPilot — New Ticket {URGENCY_EMOJI.get(code,'')} {code}",
|
| 498 |
"fields": [
|
| 499 |
-
{"title": "Location",
|
| 500 |
-
{"title": "Category",
|
| 501 |
-
{"title": "Tone",
|
| 502 |
-
{"title": "SLA",
|
| 503 |
{"title": "Tenant Message", "value": result["tenant_message"][:300]},
|
| 504 |
{"title": "Similar Tickets", "value": sim_lines},
|
| 505 |
{"title": "Contractor", "value": con_text},
|
|
@@ -509,22 +744,36 @@ def send_to_slack(result_json):
|
|
| 509 |
}]}
|
| 510 |
try:
|
| 511 |
r = requests.post(SLACK_WEBHOOK, json=payload, timeout=10)
|
| 512 |
-
|
|
|
|
|
|
|
| 513 |
except Exception as e:
|
| 514 |
return f"Request failed: {e}"
|
| 515 |
|
| 516 |
-
|
| 517 |
-
|
|
|
|
|
|
|
| 518 |
"'HEAT/HOT WATER','PLUMBING','ELECTRIC','ELEVATOR',"
|
| 519 |
"'PAINT/PLASTER','WATER LEAK','DOOR/WINDOW','FLOORING/STAIRS'"
|
| 520 |
)
|
| 521 |
|
| 522 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
def fetch_nyc311():
|
| 524 |
url = (
|
| 525 |
"https://data.cityofnewyork.us/resource/erm2-nwe9.json"
|
| 526 |
f"?$limit=25&$order=created_date+DESC"
|
| 527 |
-
f"&$where=complaint_type+IN+({
|
| 528 |
)
|
| 529 |
fetched_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 530 |
try:
|
|
@@ -536,49 +785,78 @@ def fetch_nyc311():
|
|
| 536 |
"Borough": d.get("borough", "-").title(),
|
| 537 |
"Complaint": d.get("complaint_type", ""),
|
| 538 |
"Detail": d.get("descriptor", ""),
|
| 539 |
-
"Status": d.get("status", ""),
|
|
|
|
|
|
|
| 540 |
"Last Updated": (d.get("resolution_action_updated_date")
|
| 541 |
or d.get("created_date", ""))[:16].replace("T", " "),
|
| 542 |
} for d in data]
|
| 543 |
-
|
|
|
|
| 544 |
except Exception as e:
|
| 545 |
empty = pd.DataFrame(columns=["Reported", "Borough", "Complaint", "Detail", "Status", "Last Updated"])
|
| 546 |
-
return empty, f"Error: {e} · {fetched_at}"
|
| 547 |
|
| 548 |
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
)
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
r.raise_for_status()
|
| 558 |
-
data = r.json()
|
| 559 |
-
idx = int(row_index) if row_index is not None else 0
|
| 560 |
-
idx = max(0, min(idx, len(data) - 1))
|
| 561 |
-
row = data[idx]
|
| 562 |
-
text = (f"{row.get('complaint_type','')}: {row.get('descriptor','')} "
|
| 563 |
-
f"at {row.get('incident_address','')}")
|
| 564 |
-
return process(text, row.get("incident_address", "NYC")[:20], "311")
|
| 565 |
-
except Exception as e:
|
| 566 |
-
return _EMPTY_TIMELINE, "", "", "", f"Error fetching 311 data: {e}", None
|
| 567 |
|
| 568 |
-
# ── Autocomplete ──────────────────────────────────────────────────────────────
|
| 569 |
-
N_SUGGESTIONS = 5
|
| 570 |
|
|
|
|
|
|
|
| 571 |
|
| 572 |
def get_suggestions(text):
|
| 573 |
text = (text or "").strip()
|
| 574 |
if not text or not AUTOCOMPLETE_PHRASES:
|
| 575 |
return [gr.update(value="", visible=False) for _ in range(N_SUGGESTIONS)] + [[]]
|
| 576 |
-
t
|
| 577 |
-
starts
|
| 578 |
contains = [p for p in AUTOCOMPLETE_PHRASES if t in p.lower() and p not in starts]
|
| 579 |
-
matches
|
| 580 |
-
labels
|
| 581 |
-
updates
|
| 582 |
gr.update(value=labels[i], visible=True) if i < len(matches)
|
| 583 |
else gr.update(value="", visible=False)
|
| 584 |
for i in range(N_SUGGESTIONS)
|
|
@@ -586,284 +864,399 @@ def get_suggestions(text):
|
|
| 586 |
return updates + [matches]
|
| 587 |
|
| 588 |
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
.
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
}
|
| 631 |
-
.pp-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
|
|
|
|
|
|
| 635 |
}
|
| 636 |
-
.pp-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
}
|
| 643 |
-
.pp-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
}
|
| 650 |
-
.pp-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
max-width: 82%;
|
| 654 |
-
background: var(--pp-card);
|
| 655 |
-
border: 1px solid var(--pp-border);
|
| 656 |
-
border-radius: 14px 14px 4px 14px;
|
| 657 |
-
padding: 10px 14px;
|
| 658 |
}
|
| 659 |
-
.pp-
|
| 660 |
-
.pp-
|
| 661 |
-
.pp-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
.pp-dot-purple { background: var(--pp-accent-a); }
|
| 665 |
-
.pp-dot-pink { background: #d4537e; }
|
| 666 |
-
.pp-card {
|
| 667 |
-
flex: 1;
|
| 668 |
-
background: var(--pp-card);
|
| 669 |
-
border: 1px solid var(--pp-border);
|
| 670 |
-
border-radius: 12px;
|
| 671 |
-
padding: 12px 14px;
|
| 672 |
}
|
| 673 |
-
.pp-card-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
}
|
| 677 |
-
.
|
| 678 |
-
|
| 679 |
-
.
|
| 680 |
-
|
| 681 |
-
|
|
|
|
| 682 |
}
|
| 683 |
-
.
|
| 684 |
-
|
| 685 |
-
|
|
|
|
| 686 |
}
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
.pp-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
.pp-empty-state { color: var(--pp-text-dim); font-size: 13px; padding: 24px 4px; }
|
| 693 |
-
.pp-pre {
|
| 694 |
-
white-space: pre-wrap; font-family: inherit;
|
| 695 |
-
font-size: 12.5px; line-height: 1.6; margin: 0; color: var(--pp-text);
|
| 696 |
}
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
}
|
| 701 |
-
.
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
|
|
|
|
|
|
| 711 |
}
|
| 712 |
-
.pp-
|
|
|
|
|
|
|
| 713 |
"""
|
| 714 |
|
| 715 |
-
# ── UI ───────────────────────────────────────────────────────────────────
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
|
| 742 |
with gr.Tabs():
|
| 743 |
|
| 744 |
-
# ── Process Ticket tab ────────────────────────────────────────────────
|
| 745 |
with gr.Tab("🎫 Process Ticket"):
|
| 746 |
-
with gr.
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
suggestion_btns = [
|
| 769 |
-
|
| 770 |
-
for _ in range(N_SUGGESTIONS)
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
|
| 814 |
-
# Wire Quick Starters
|
| 815 |
for cat, btn in qs_btns:
|
| 816 |
btn.click(
|
| 817 |
-
lambda
|
| 818 |
-
outputs=[msg_box, bldg_box, unit_box,
|
| 819 |
-
|
| 820 |
)
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
inputs=[msg_box],
|
| 826 |
outputs=suggestion_btns + [suggestions_state],
|
| 827 |
)
|
| 828 |
-
for i,
|
| 829 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
|
|
|
| 835 |
)
|
| 836 |
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
"
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
"Pick a row and run it through the full pipeline."
|
| 844 |
-
)
|
| 845 |
-
nyc_refresh = gr.Button("🔄 Refresh Feed", variant="primary")
|
| 846 |
-
nyc_status = gr.Markdown("")
|
| 847 |
-
nyc_table = gr.DataFrame(interactive=False)
|
| 848 |
-
|
| 849 |
-
gr.Markdown("---")
|
| 850 |
-
row_index = gr.Number(label="Row # to analyze (0-based)", value=0, precision=0)
|
| 851 |
-
process_311_btn = gr.Button("✨ Analyze this complaint", variant="primary")
|
| 852 |
-
|
| 853 |
-
triage_label_311 = gr.Markdown("")
|
| 854 |
-
timeline_311 = gr.HTML(_EMPTY_TIMELINE)
|
| 855 |
-
with gr.Accordion("📄 Raw work order / reply text", open=False):
|
| 856 |
-
work_order_311 = gr.Textbox(label="Work order", lines=6, interactive=False, show_copy_button=True)
|
| 857 |
-
reply_311 = gr.Textbox(label="Tenant reply", lines=4, interactive=False, show_copy_button=True)
|
| 858 |
-
status_311 = gr.Markdown("")
|
| 859 |
-
state_311 = gr.State(None)
|
| 860 |
-
|
| 861 |
-
nyc_refresh.click(fn=fetch_nyc311, outputs=[nyc_table, nyc_status])
|
| 862 |
-
process_311_btn.click(
|
| 863 |
-
process_311_row,
|
| 864 |
-
inputs=[row_index],
|
| 865 |
-
outputs=[timeline_311, work_order_311, reply_311,
|
| 866 |
-
triage_label_311, status_311, state_311],
|
| 867 |
)
|
| 868 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
demo.launch()
|
|
|
|
| 1 |
"""
|
| 2 |
+
PropertyPilot v2 — AI Maintenance Assistant
|
| 3 |
+
Built from the merged notebook (Final_Project_Matan.ipynb): retrieval-grounded
|
| 4 |
+
triage, contractor ranking, tone-matched generation, Slack dispatch, NYC-311 feed.
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
Pipeline: tenant message -> retrieval (FAISS, MiniLM) -> triage + contractor rank
|
| 7 |
+
-> work order + tone-matched reply (Qwen2.5)
|
| 8 |
+
Bonuses: Slack ops-channel dispatch (+5%), NYC-311 live feed (+5%)
|
| 9 |
+
"""
|
| 10 |
import os
|
| 11 |
import re
|
| 12 |
import json
|
| 13 |
+
import time
|
| 14 |
+
from collections import Counter
|
| 15 |
from datetime import datetime
|
| 16 |
+
from threading import Thread
|
| 17 |
|
| 18 |
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
import pandas as pd
|
| 21 |
import faiss
|
| 22 |
import gradio as gr
|
|
|
|
| 23 |
import requests
|
| 24 |
from sentence_transformers import SentenceTransformer
|
| 25 |
from huggingface_hub import hf_hub_download
|
| 26 |
+
from transformers import (AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer,
|
| 27 |
+
DistilBertModel, DistilBertTokenizerFast)
|
| 28 |
+
|
| 29 |
+
# ── Configuration ──────────────────────────────────────────────────────────────
|
| 30 |
+
HF_REPO = "propertypilot/property-pilot-tickets" # canonical dataset + recommender repo
|
| 31 |
+
HF_TOKEN = os.environ.get("HF_TOKEN") or None
|
| 32 |
|
| 33 |
+
# Slack webhook must be set as a Space Secret (Settings > Variables and secrets),
|
| 34 |
+
# NEVER hardcoded here — a hardcoded webhook in a public Space lets anyone post
|
| 35 |
+
# to the channel.
|
| 36 |
SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")
|
| 37 |
+
|
| 38 |
+
GEN_MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
|
| 39 |
|
| 40 |
SLA_MAP = {
|
| 41 |
"P1": "4 hours",
|
|
|
|
| 44 |
"P4": "7-14 business days",
|
| 45 |
}
|
| 46 |
|
| 47 |
+
# The fine-tuned classifier only returns short codes (P1-P4); the rest of the
|
| 48 |
+
# app (dataset rows, generation prompt, contractor logic) uses the full
|
| 49 |
+
# descriptive urgency string, so map back to that for consistency.
|
| 50 |
+
URGENCY_FULL = {
|
| 51 |
+
"P1": "P1 Emergency (4h)",
|
| 52 |
+
"P2": "P2 Urgent (24h)",
|
| 53 |
+
"P3": "P3 Standard (3-5d)",
|
| 54 |
+
"P4": "P4 Scheduled (7-14d)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
TONE_INSTRUCTIONS = {
|
|
|
|
| 62 |
"Give a SPECIFIC date/time commitment."),
|
| 63 |
"passive-aggressive": ("The tenant is passive-aggressive. Be extra warm and proactive. "
|
| 64 |
"Avoid any defensiveness. Thank them for flagging the issue."),
|
| 65 |
+
"polite-formal": ("The tenant is polite and formal. Match their register exactly -- "
|
| 66 |
"be professional, precise, and respectful."),
|
| 67 |
"vague-confused": ("The tenant is unsure about the problem. Be clear and patient. "
|
| 68 |
"Ask ONE specific clarifying question if the issue is ambiguous."),
|
|
|
|
| 70 |
"Use a numbered list if helpful."),
|
| 71 |
}
|
| 72 |
|
| 73 |
+
URGENCY_EMOJI = {"P1": "🔴", "P2": "🟠", "P3": "🔵", "P4": "🟢"}
|
| 74 |
+
URGENCY_HEX = {"P1": "#FF0000", "P2": "#FF8C00", "P3": "#0099FF", "P4": "#00CC44"}
|
| 75 |
+
|
| 76 |
+
YELP_KEYWORDS = {
|
| 77 |
+
"Plumbing": "plumber",
|
| 78 |
+
"Electrical": "electrician",
|
| 79 |
+
"HVAC": "hvac+repair",
|
| 80 |
+
"Appliances": "appliance+repair",
|
| 81 |
+
"Elevator": "elevator+repair",
|
| 82 |
+
"Structural": "contractor+structural+repair",
|
| 83 |
+
"Pests": "pest+control",
|
| 84 |
+
"Common Areas": "handyman",
|
| 85 |
+
"Security": "locksmith+security",
|
| 86 |
+
"Noise": "soundproofing+contractor",
|
| 87 |
}
|
| 88 |
|
| 89 |
+
# ── Load data + models at startup ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
print("Downloading files from HF Hub...")
|
| 91 |
+
|
| 92 |
csv_path = hf_hub_download(repo_id=HF_REPO, filename="propertypilot_tickets.csv",
|
| 93 |
repo_type="dataset", token=HF_TOKEN)
|
| 94 |
faiss_path = hf_hub_download(repo_id=HF_REPO, filename="recommender/index.faiss",
|
|
|
|
| 101 |
df["urgency_code"] = df["urgency"].str[:2]
|
| 102 |
print(f"Dataset: {len(df):,} rows")
|
| 103 |
|
| 104 |
+
# Per-specialty contractor roster for ranking (see recommend_similar). Built
|
| 105 |
+
# once at startup: contractor_specialty often disagrees with the category of
|
| 106 |
+
# the ticket a contractor happens to have handled historically (67% of rows
|
| 107 |
+
# in this dataset — likely a synthetic-generation artifact), so contractors
|
| 108 |
+
# must be looked up by their own specialty, not by which tickets they appear
|
| 109 |
+
# on, or the "recommended contractor" ends up being a specialist in a
|
| 110 |
+
# different trade than the one the tenant's issue needs.
|
| 111 |
+
_CONTRACTOR_COLS = ["contractor_id", "contractor_specialty", "contractor_success_rate",
|
| 112 |
+
"contractor_avg_resolution_hours", "contractor_avg_cost"]
|
| 113 |
+
_contractors = df[_CONTRACTOR_COLS].drop_duplicates("contractor_id")
|
| 114 |
+
CONTRACTORS_BY_SPECIALTY = {
|
| 115 |
+
specialty: group.to_dict("records")
|
| 116 |
+
for specialty, group in _contractors.groupby("contractor_specialty")
|
| 117 |
+
}
|
| 118 |
+
print(f"Contractor roster: {len(_contractors)} contractors across "
|
| 119 |
+
f"{len(CONTRACTORS_BY_SPECIALTY)} specialties")
|
| 120 |
+
|
| 121 |
with open(config_path) as f:
|
| 122 |
cfg = json.load(f)
|
| 123 |
|
|
|
|
| 126 |
embed_model = SentenceTransformer(cfg["model_name"])
|
| 127 |
print(f"Embedder: {cfg['model_name']} | MIN_SIM={MIN_SIM} | {index.ntotal:,} vectors")
|
| 128 |
|
| 129 |
+
print(f"Loading generation model: {GEN_MODEL}...")
|
| 130 |
+
gen_tokenizer = AutoTokenizer.from_pretrained(GEN_MODEL)
|
| 131 |
+
gen_model = AutoModelForCausalLM.from_pretrained(GEN_MODEL, torch_dtype=torch.float32)
|
|
|
|
| 132 |
gen_model.eval()
|
|
|
|
| 133 |
|
| 134 |
+
# ── Fine-tuned triage classifier (Bonus: two-head DistilBERT) ─────────────────
|
| 135 |
+
# Independent cross-check against the retrieval-based triage below -- a model
|
| 136 |
+
# actually trained on this dataset, not just nearest-neighbor lookup. Saved as
|
| 137 |
+
# a state_dict only, so the exact architecture from the notebook (Fine-tuning
|
| 138 |
+
# 4D) has to be redefined here to load it.
|
| 139 |
+
print("Loading fine-tuned triage classifier...")
|
| 140 |
+
TRIAGE_REPO = "propertypilot/property-pilot-triage"
|
| 141 |
+
|
| 142 |
+
class TriageClassifier(nn.Module):
|
| 143 |
+
def __init__(self, n_categories, n_urgencies):
|
| 144 |
+
super().__init__()
|
| 145 |
+
self.bert = DistilBertModel.from_pretrained("distilbert-base-uncased")
|
| 146 |
+
hidden = self.bert.config.hidden_size
|
| 147 |
+
self.dropout = nn.Dropout(0.2)
|
| 148 |
+
self.cat_head = nn.Linear(hidden, n_categories)
|
| 149 |
+
self.urg_head = nn.Linear(hidden, n_urgencies)
|
| 150 |
+
|
| 151 |
+
def forward(self, input_ids, attention_mask):
|
| 152 |
+
out = self.bert(input_ids=input_ids, attention_mask=attention_mask)
|
| 153 |
+
cls = self.dropout(out.last_hidden_state[:, 0])
|
| 154 |
+
return self.cat_head(cls), self.urg_head(cls)
|
| 155 |
+
|
| 156 |
+
_triage_labels_path = hf_hub_download(TRIAGE_REPO, "label_map.json")
|
| 157 |
+
_triage_weights_path = hf_hub_download(TRIAGE_REPO, "model.pt")
|
| 158 |
+
with open(_triage_labels_path) as f:
|
| 159 |
+
_triage_labels = json.load(f)
|
| 160 |
+
TRIAGE_CATEGORIES = [_triage_labels["category_id2label"][str(i)]
|
| 161 |
+
for i in range(len(_triage_labels["category_id2label"]))]
|
| 162 |
+
TRIAGE_URGENCIES = [_triage_labels["urgency_id2label"][str(i)]
|
| 163 |
+
for i in range(len(_triage_labels["urgency_id2label"]))]
|
| 164 |
+
|
| 165 |
+
triage_tokenizer = DistilBertTokenizerFast.from_pretrained("distilbert-base-uncased")
|
| 166 |
+
triage_model = TriageClassifier(len(TRIAGE_CATEGORIES), len(TRIAGE_URGENCIES))
|
| 167 |
+
triage_model.load_state_dict(torch.load(_triage_weights_path, map_location="cpu"))
|
| 168 |
+
triage_model.eval()
|
| 169 |
+
print(f"Triage classifier loaded: {len(TRIAGE_CATEGORIES)} categories, {len(TRIAGE_URGENCIES)} urgencies")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def classify_triage(text):
|
| 173 |
+
"""(category, urgency) prediction from the fine-tuned model -- this is the
|
| 174 |
+
primary triage signal shown to the user (test macro-F1: 0.975 category /
|
| 175 |
+
0.766 urgency). Retrieval's own top-neighbor category/urgency is shown
|
| 176 |
+
alongside as a cross-check, not the other way around, because a nearest-
|
| 177 |
+
neighbor vote can be pulled off course by a couple of superficially similar
|
| 178 |
+
but differently-severe historical tickets, while the classifier reads the
|
| 179 |
+
current message's own content directly."""
|
| 180 |
+
enc = triage_tokenizer(text, truncation=True, padding="max_length",
|
| 181 |
+
max_length=128, return_tensors="pt")
|
| 182 |
+
with torch.no_grad():
|
| 183 |
+
cat_logits, urg_logits = triage_model(enc["input_ids"], enc["attention_mask"])
|
| 184 |
+
cat = TRIAGE_CATEGORIES[cat_logits.argmax(dim=1).item()]
|
| 185 |
+
urg = TRIAGE_URGENCIES[urg_logits.argmax(dim=1).item()]
|
| 186 |
+
return cat, urg
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
print("Ready.")
|
| 190 |
+
|
| 191 |
+
# ── Precomputed Quick Starters + autocomplete phrases (from the notebook) ─────
|
| 192 |
+
# quick_starters: {category: {tenant_message, building_id, unit, work_order,
|
| 193 |
+
# tenant_reply, ...}} — the LLM part is cached, so
|
| 194 |
+
# clicking a Quick Starter never calls the generation model.
|
| 195 |
+
# autocomplete_phrases: 40 representative (category, urgency) ticket texts used
|
| 196 |
+
# to power the "suggestions while typing" row under the message box.
|
| 197 |
QUICK_STARTERS_CACHE = {}
|
| 198 |
AUTOCOMPLETE_PHRASES = []
|
|
|
|
| 199 |
try:
|
| 200 |
qs_path = hf_hub_download(repo_id=HF_REPO, filename="recommender/quick_starters_v2.json",
|
| 201 |
repo_type="dataset", token=HF_TOKEN)
|
|
|
|
| 203 |
_qs_data = json.load(f)
|
| 204 |
QUICK_STARTERS_CACHE = _qs_data.get("quick_starters", {})
|
| 205 |
AUTOCOMPLETE_PHRASES = _qs_data.get("autocomplete_phrases", [])
|
| 206 |
+
print(f"Loaded {len(QUICK_STARTERS_CACHE)} cached Quick Starters, "
|
|
|
|
| 207 |
f"{len(AUTOCOMPLETE_PHRASES)} autocomplete phrases.")
|
| 208 |
except Exception as e:
|
| 209 |
+
print(f"Could not load quick_starters_v2.json ({e}) — Quick Starters/autocomplete disabled.")
|
| 210 |
+
|
| 211 |
+
# Exact-text lookup so pasting one of the 10 cached messages into the free-text
|
| 212 |
+
# box (instead of clicking its button) is just as instant and deterministic.
|
| 213 |
+
TEXT_TO_CACHED = {c["tenant_message"].strip(): c for c in QUICK_STARTERS_CACHE.values()}
|
| 214 |
+
|
| 215 |
+
CATEGORY_ICON = {
|
| 216 |
+
"Plumbing": "🚰", "Electrical": "⚡", "HVAC": "🌡️", "Appliances": "🔌",
|
| 217 |
+
"Elevator": "🛗", "Structural": "🧱", "Pests": "🐜", "Common Areas": "🏢",
|
| 218 |
+
"Security": "🔒", "Noise": "🔊",
|
| 219 |
+
}
|
| 220 |
|
| 221 |
+
|
| 222 |
+
# ── Recommender (Part 3: retrieval + contractor ranking) ──────────────────────
|
| 223 |
def _encode(text):
|
|
|
|
| 224 |
return embed_model.encode(
|
| 225 |
+
[cfg["query_prefix"] + text],
|
| 226 |
normalize_embeddings=True,
|
| 227 |
convert_to_numpy=True,
|
| 228 |
).astype("float32")
|
|
|
|
| 231 |
def recommend_similar(query_text, top_k=3, building_id=None):
|
| 232 |
qvec = _encode(query_text)
|
| 233 |
sims_r, idxs_r = index.search(qvec, top_k * 3 + 1)
|
| 234 |
+
# i < len(df) guards against the FAISS index and the dataset CSV having
|
| 235 |
+
# been updated out of step with each other (the index can point past the
|
| 236 |
+
# end of a since-shrunk dataset) — without this, a mismatch crashes every
|
| 237 |
+
# query whose nearest neighbors happen to land past the current row count.
|
| 238 |
cand = [(float(s), int(i)) for s, i in zip(sims_r[0], idxs_r[0])
|
| 239 |
+
if 0 <= i < len(df) and s >= MIN_SIM]
|
| 240 |
if not cand:
|
| 241 |
return {"status": "no_match", "confidence": "low",
|
| 242 |
"message": "No similar ticket found above the similarity threshold.",
|
| 243 |
+
"similar_tickets": [], "contractor_rank": [], "classifier_check": None}
|
| 244 |
if building_id:
|
| 245 |
same = [c for c in cand if df.iloc[c[1]]["building_id"] == building_id]
|
| 246 |
rest = [c for c in cand if df.iloc[c[1]]["building_id"] != building_id]
|
|
|
|
| 252 |
for sim, i in cand:
|
| 253 |
row = df.iloc[i]
|
| 254 |
similar.append({
|
| 255 |
+
"similarity": round(sim, 3),
|
| 256 |
+
"ticket_id": row["ticket_id"],
|
| 257 |
"category": row["category"],
|
| 258 |
"urgency": row["urgency"],
|
| 259 |
"tenant_tone": row["tenant_tone"],
|
|
|
|
| 262 |
"cost_usd": float(row["cost_usd"]),
|
| 263 |
"contractor_id": row["contractor_id"],
|
| 264 |
"resolution_notes": row["resolution_notes"],
|
| 265 |
+
"building_id": row["building_id"],
|
| 266 |
})
|
| 267 |
|
| 268 |
+
top_cat = Counter(t["category"] for t in similar).most_common(1)[0][0]
|
| 269 |
+
top_cat_n = Counter(t["category"] for t in similar)[top_cat]
|
| 270 |
+
confidence = "high" if top_cat_n == top_k else "medium" if top_cat_n > 1 else "low"
|
| 271 |
+
|
| 272 |
+
# Rank contractors who actually specialize in this ticket's category —
|
| 273 |
+
# NOT whichever contractor happened to be attached to a similar-worded
|
| 274 |
+
# historical ticket (contractor_specialty disagrees with that ticket's
|
| 275 |
+
# own category most of the time in this dataset, so that pool was often
|
| 276 |
+
# the wrong trade entirely). similar[0]["category"] matches the triage
|
| 277 |
+
# category shown in the UI (fmt_triage), so ranking stays consistent
|
| 278 |
+
# with what the tenant sees.
|
| 279 |
+
ticket_category = similar[0]["category"]
|
| 280 |
+
roster = CONTRACTORS_BY_SPECIALTY.get(ticket_category, [])
|
| 281 |
+
if not roster:
|
| 282 |
+
# No contractor lists this exact category as their specialty (not
|
| 283 |
+
# expected — every category has specialists in this dataset) — fall
|
| 284 |
+
# back to the old behavior rather than showing no contractor at all.
|
| 285 |
+
roster = [{"contractor_id": t["contractor_id"],
|
| 286 |
+
"contractor_specialty": df.loc[df["contractor_id"] == t["contractor_id"],
|
| 287 |
+
"contractor_specialty"].iloc[0],
|
| 288 |
+
"contractor_success_rate": df.loc[df["contractor_id"] == t["contractor_id"],
|
| 289 |
+
"contractor_success_rate"].iloc[0],
|
| 290 |
+
"contractor_avg_resolution_hours": df.loc[df["contractor_id"] == t["contractor_id"],
|
| 291 |
+
"contractor_avg_resolution_hours"].iloc[0],
|
| 292 |
+
"contractor_avg_cost": df.loc[df["contractor_id"] == t["contractor_id"],
|
| 293 |
+
"contractor_avg_cost"].iloc[0]}
|
| 294 |
+
for t in {t["contractor_id"]: t for t in similar}.values()]
|
| 295 |
+
|
| 296 |
+
max_hours = max(r["contractor_avg_resolution_hours"] for r in roster) or 1
|
| 297 |
+
max_cost = max(r["contractor_avg_cost"] for r in roster) or 1
|
| 298 |
+
contractor_rank = sorted(
|
| 299 |
+
[{"contractor_id": r["contractor_id"],
|
| 300 |
+
"specialty": r["contractor_specialty"],
|
| 301 |
+
"success_rate": float(r["contractor_success_rate"]),
|
| 302 |
+
"avg_hours": float(r["contractor_avg_resolution_hours"]),
|
| 303 |
+
"avg_cost": float(r["contractor_avg_cost"]),
|
| 304 |
+
"score": (0.5 * float(r["contractor_success_rate"])
|
| 305 |
+
- 0.3 * float(r["contractor_avg_resolution_hours"]) / max_hours
|
| 306 |
+
- 0.2 * float(r["contractor_avg_cost"]) / max_cost),
|
| 307 |
+
"reason": (f"{r['contractor_specialty']} specialist — "
|
| 308 |
+
f"success={r['contractor_success_rate']:.0%}, "
|
| 309 |
+
f"avg {r['contractor_avg_resolution_hours']:.0f}h, "
|
| 310 |
+
f"${r['contractor_avg_cost']:.0f}/job")}
|
| 311 |
+
for r in roster],
|
| 312 |
+
key=lambda x: -x["score"],
|
| 313 |
+
)[:5]
|
| 314 |
+
|
| 315 |
+
# Independent cross-check: the fine-tuned classifier predicts triage from
|
| 316 |
+
# the raw text alone, with no knowledge of the FAISS neighbors at all --
|
| 317 |
+
# agreement between two independently-derived triages is a much stronger
|
| 318 |
+
# confidence signal than either one alone.
|
| 319 |
+
cls_category, cls_urgency = classify_triage(query_text)
|
| 320 |
+
classifier_check = {
|
| 321 |
+
"category": cls_category,
|
| 322 |
+
"urgency": cls_urgency,
|
| 323 |
+
"agrees_category": cls_category == ticket_category,
|
| 324 |
+
"agrees_urgency": cls_urgency == similar[0]["urgency"][:2],
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
return {"status": "ok", "confidence": confidence,
|
| 328 |
+
"message": f"Top category: {top_cat} ({top_cat_n}/{top_k} neighbors agree)",
|
| 329 |
+
"similar_tickets": similar, "contractor_rank": contractor_rank,
|
| 330 |
+
"classifier_check": classifier_check}
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ── LLM generation (Part 4: work order + tone-matched reply) ──────────────────
|
| 334 |
+
def _generate_stream(prompt, max_new_tokens=500, temperature=0.6):
|
| 335 |
+
"""Yields the growing generated text as tokens arrive, instead of blocking
|
| 336 |
+
until the full 500-token response is done. generate() runs in a background
|
| 337 |
+
thread (it's synchronous) while the main thread reads off the streamer —
|
| 338 |
+
the standard transformers pattern for streaming with .generate()."""
|
| 339 |
+
messages = [
|
| 340 |
+
{"role": "system", "content": "You are an assistant for a property management company."},
|
| 341 |
+
{"role": "user", "content": prompt},
|
| 342 |
]
|
| 343 |
+
text = gen_tokenizer.apply_chat_template(
|
| 344 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 345 |
+
)
|
| 346 |
+
inputs = gen_tokenizer(text, return_tensors="pt")
|
| 347 |
+
streamer = TextIteratorStreamer(
|
| 348 |
+
gen_tokenizer, skip_prompt=True, skip_special_tokens=True
|
| 349 |
+
)
|
| 350 |
+
gen_kwargs = dict(
|
| 351 |
+
**inputs, max_new_tokens=max_new_tokens, temperature=temperature,
|
| 352 |
+
do_sample=True, top_p=0.9, repetition_penalty=1.1,
|
| 353 |
+
pad_token_id=gen_tokenizer.eos_token_id, streamer=streamer,
|
| 354 |
+
)
|
| 355 |
+
thread = Thread(target=_run_generate, args=(gen_kwargs, streamer))
|
| 356 |
+
thread.start()
|
| 357 |
+
partial = ""
|
| 358 |
+
try:
|
| 359 |
+
for chunk in streamer:
|
| 360 |
+
partial += chunk
|
| 361 |
+
yield partial
|
| 362 |
+
finally:
|
| 363 |
+
thread.join()
|
| 364 |
+
if not partial.strip():
|
| 365 |
+
yield "[Generation error — please try again.]"
|
| 366 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
+
def _run_generate(gen_kwargs, streamer):
|
|
|
|
|
|
|
| 369 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
with torch.no_grad():
|
| 371 |
+
gen_model.generate(**gen_kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
except Exception as e:
|
| 373 |
+
# generate() normally calls streamer.end() itself when it finishes;
|
| 374 |
+
# if it raises first that never happens, so the consumer thread would
|
| 375 |
+
# block forever on the queue. Close it here so the `for chunk in
|
| 376 |
+
# streamer` loop exits instead of hanging.
|
| 377 |
+
print(f"[Generation error: {e}]")
|
| 378 |
+
streamer.end()
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
# A 0.5B model doesn't reliably reproduce the exact "WORK ORDER:" / "TENANT
|
| 382 |
+
# REPLY:" strings from the prompt — in practice it also writes "WORK ORDER**"
|
| 383 |
+
# (no colon), "TENANT RESPONSE", "REPLY TO TENANT", etc. The colon and the
|
| 384 |
+
# exact word order were previously required, so any of those variants made
|
| 385 |
+
# the split fail entirely and dumped the whole raw generation (including a
|
| 386 |
+
# perfectly good tenant-facing message) into the work_order box.
|
| 387 |
+
_WORK_ORDER_HEADER = re.compile(r"[\*\#\s]*work\s*order[\*\#\s]*:?[\*\#\s]*", re.IGNORECASE)
|
| 388 |
+
_TENANT_REPLY_HEADER = re.compile(
|
| 389 |
+
r"[\*\#\s]*(?:tenant\s*(?:reply|response)|(?:reply|response)\s*to\s*tenant)[\*\#\s]*:?[\*\#\s]*",
|
| 390 |
+
re.IGNORECASE,
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
# Same intent as the notebook's Part 4 generation pipeline (_is_valid_generation
|
| 394 |
+
# / BAD_PHRASES_GEN) — the deployed app never had this check at all, so a real
|
| 395 |
+
# refusal like "I'm sorry, but I can't assist with this task." could reach the
|
| 396 |
+
# tenant reply box unfiltered. But the notebook's original list banned bare
|
| 397 |
+
# "i'm sorry" / "i apologize", which also matches completely legitimate,
|
| 398 |
+
# non-refusal replies ("I'm sorry, but I don't have information about a
|
| 399 |
+
# previous case, could you clarify?") -- measured a ~50% false-positive
|
| 400 |
+
# discard rate on some queries because of this. Phrases below require an
|
| 401 |
+
# actual refusal/inability statement, not just an apologetic opener.
|
| 402 |
+
_BAD_PHRASES = ("i cannot assist", "i can't assist", "i can not assist",
|
| 403 |
+
"as an ai", "as a language model",
|
| 404 |
+
"i'm not able to help", "i am not able to help",
|
| 405 |
+
"i'm sorry, but i can", "i'm sorry but i can", "i'm sorry, i cannot",
|
| 406 |
+
"unable to fulfill", "unable to complete this request")
|
| 407 |
+
|
| 408 |
+
# The model is occasionally handed its own instructions back as if they were
|
| 409 |
+
# content -- e.g. it paraphrases the prompt's "Tenant tone: X -- Y" line
|
| 410 |
+
# instead of writing an actual reply ("Reply Tone: Passive-Age -- the
|
| 411 |
+
# tenant's tone is one of frustration...", or a trailing "Panic-Cap Tone\n\n
|
| 412 |
+
# This reply should be extremely calm..."). This is a paraphrase, not a fixed
|
| 413 |
+
# string, so there's no reliable place to cut it out and keep the rest --
|
| 414 |
+
# treated as a failed generation (same bucket as a refusal) so the caller's
|
| 415 |
+
# existing fallback substitution kicks in instead of leaking it to the user.
|
| 416 |
+
_TONE_ECHO_PATTERNS = (
|
| 417 |
+
re.compile(r"^\s*(?:reply\s*tone|tenant\s*tone|tone)\s*:", re.IGNORECASE),
|
| 418 |
+
re.compile(r"\n\s*[A-Za-z][A-Za-z\- ]{2,30}\btone\b\s*\n", re.IGNORECASE),
|
| 419 |
+
re.compile(r"the tenant'?s tone (?:is|remains|was)\b", re.IGNORECASE),
|
| 420 |
+
re.compile(r"this reply should be\b", re.IGNORECASE),
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def _has_tone_echo(text):
|
| 425 |
+
return any(p.search(text) for p in _TONE_ECHO_PATTERNS)
|
| 426 |
+
|
| 427 |
|
| 428 |
+
def _is_valid_generation(text):
|
| 429 |
+
if not text or len(text) < 15:
|
| 430 |
+
return False
|
| 431 |
+
if any(p in text.lower() for p in _BAD_PHRASES):
|
| 432 |
+
return False
|
| 433 |
+
return not _has_tone_echo(text)
|
| 434 |
|
|
|
|
|
|
|
| 435 |
|
| 436 |
+
_MD_BOLD = re.compile(r"\*\*(.+?)\*\*")
|
| 437 |
+
_MD_ITALIC = re.compile(r"(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)")
|
| 438 |
|
| 439 |
+
# The model sometimes keeps generating past the first TENANT REPLY section and
|
| 440 |
+
# hallucinates a second WORK ORDER / TENANT REPLY / TICKET DETAILS round
|
| 441 |
+
# (occasionally numbered, "Work Order #2") -- unlike the tone-echo leak above,
|
| 442 |
+
# this one has an unambiguous, fixed marker, so it's safe to just cut there.
|
| 443 |
+
_SECOND_ROUND_HEADER = re.compile(
|
| 444 |
+
r"\n[\*\#\s]*(?:work\s*order(?:\s*#\s*\d+)?|tenant\s*(?:reply|response)|ticket\s*details)[\*\#\s]*:?",
|
| 445 |
+
re.IGNORECASE,
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
# A trailing sign-off line that's *only* a bracket placeholder ("[Your Name]",
|
| 449 |
+
# "[Your Position]") is never legitimate content -- safe to drop outright,
|
| 450 |
+
# unlike an inline placeholder ("Dear [Tenant's Name],") where removing just
|
| 451 |
+
# the bracket would leave an awkward gap.
|
| 452 |
+
_PLACEHOLDER_LINE = re.compile(r"^\s*\[[^\]]{2,40}\]\s*$", re.MULTILINE)
|
| 453 |
+
|
| 454 |
+
# The model is handed the real priority/SLA in its prompt but sometimes
|
| 455 |
+
# paraphrases -- or outright hallucinates -- its own version inside the WORK
|
| 456 |
+
# ORDER section it generates, contradicting the Triage card above it (which
|
| 457 |
+
# always shows the fine-tuned classifier's real value). Strip whatever the
|
| 458 |
+
# model wrote and replace it with the authoritative line so the two can never
|
| 459 |
+
# disagree.
|
| 460 |
+
_PRIORITY_LINE = re.compile(r"^\s*-?\s*Priority:.*$", re.MULTILINE | re.IGNORECASE)
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _inject_authoritative_priority(work_order, urgency, sla):
|
| 464 |
+
if not work_order:
|
| 465 |
+
return work_order
|
| 466 |
+
stripped = _PRIORITY_LINE.sub("", work_order).strip()
|
| 467 |
+
header = f"Priority: {urgency} | SLA: {sla}"
|
| 468 |
+
return f"{header}\n{stripped}" if stripped else header
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def _truncate_second_round(text):
|
| 472 |
+
m = _SECOND_ROUND_HEADER.search(text)
|
| 473 |
+
return text[:m.start()].rstrip() if m else text
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def _clean_section(text):
|
| 477 |
+
"""Strip markdown artifacts the model leaves in -- both at the edges (left
|
| 478 |
+
over from splitting on a header) and scattered through the middle (e.g.
|
| 479 |
+
'**To:** [Tenant's Name]'), since these render in a plain gr.Textbox, not
|
| 480 |
+
as markdown, so unstripped ** show up literally in the UI."""
|
| 481 |
+
text = _truncate_second_round(text)
|
| 482 |
+
text = _MD_BOLD.sub(r"\1", text)
|
| 483 |
+
text = _MD_ITALIC.sub(r"\1", text)
|
| 484 |
+
text = re.sub(r"^\s*#+\s*", "", text, flags=re.MULTILINE)
|
| 485 |
+
text = re.sub(r"^\s*-{3,}\s*$", "", text, flags=re.MULTILINE)
|
| 486 |
+
text = _PLACEHOLDER_LINE.sub("", text)
|
| 487 |
+
# Orphaned ** can survive the paired substitution above -- e.g. the header
|
| 488 |
+
# regex's own trailing [\*\#\s]* sometimes swallows the opening ** of the
|
| 489 |
+
# very next bold run, stranding its closing **. Anything left at this
|
| 490 |
+
# point isn't a valid pair, so it's safe to drop outright.
|
| 491 |
+
text = text.replace("**", "")
|
| 492 |
return text.strip().strip("*#").strip()
|
| 493 |
|
| 494 |
|
| 495 |
+
def _split_work_order_reply_partial(raw_text):
|
| 496 |
+
"""Header-based split of one combined generation into (work_order,
|
| 497 |
+
tenant_reply), tolerant of a still-streaming partial text: never
|
| 498 |
+
substitutes the 'Manual review required' / 'Thank you for reporting'
|
| 499 |
+
fallback text here, since an empty section usually just hasn't been
|
| 500 |
+
generated yet rather than having failed (the caller applies fallbacks —
|
| 501 |
+
and refusal detection — once streaming is done)."""
|
| 502 |
+
wo_match = _WORK_ORDER_HEADER.search(raw_text)
|
| 503 |
+
tr_match = _TENANT_REPLY_HEADER.search(raw_text)
|
| 504 |
+
|
| 505 |
+
if wo_match and tr_match and tr_match.start() > wo_match.start():
|
| 506 |
+
work_order = _clean_section(raw_text[wo_match.end():tr_match.start()])
|
| 507 |
+
tenant_reply = _clean_section(raw_text[tr_match.end():])
|
| 508 |
+
elif tr_match:
|
| 509 |
+
work_order = _clean_section(raw_text[:tr_match.start()])
|
| 510 |
+
tenant_reply = _clean_section(raw_text[tr_match.end():])
|
| 511 |
+
elif wo_match:
|
| 512 |
+
work_order = _clean_section(raw_text[wo_match.end():])
|
| 513 |
+
tenant_reply = ""
|
| 514 |
else:
|
| 515 |
+
work_order = _clean_section(raw_text)
|
| 516 |
+
tenant_reply = ""
|
| 517 |
+
return work_order, tenant_reply
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def generate_ticket_response_stream(tenant_message, category, urgency, tenant_tone,
|
| 521 |
+
building_id, unit, similar_tickets=None, contractor=None):
|
| 522 |
+
"""Same single-call prompt as before (still the main per-request latency
|
| 523 |
+
reduction versus two sequential calls) — but now yields (work_order,
|
| 524 |
+
tenant_reply) as the model streams, instead of blocking until all 340
|
| 525 |
+
tokens are done."""
|
| 526 |
+
sla = SLA_MAP.get(urgency[:2], "TBD")
|
| 527 |
+
tone_instruction = TONE_INSTRUCTIONS.get(tenant_tone, "Be professional, warm, and helpful.")
|
| 528 |
+
|
| 529 |
+
past_case = ""
|
| 530 |
if similar_tickets:
|
| 531 |
t = similar_tickets[0]
|
| 532 |
+
past_case = (f"Most similar past case: {t['category']} resolved in "
|
| 533 |
+
f"{t['resolution_hours']:.0f}h for ${t['cost_usd']:.0f}. "
|
| 534 |
+
f"Notes: {t['resolution_notes']}\n")
|
| 535 |
+
contractor_info = ""
|
| 536 |
+
if contractor:
|
| 537 |
+
contractor_info = (f"Assigned contractor: {contractor['contractor_id']} "
|
| 538 |
+
f"(specialty: {contractor['specialty']}, "
|
| 539 |
+
f"avg {contractor['avg_hours']:.0f}h, "
|
| 540 |
+
f"success {contractor['success_rate']:.0%})\n")
|
| 541 |
+
|
| 542 |
prompt = (
|
| 543 |
+
"You are an assistant for a property management company. Given the ticket "
|
| 544 |
+
"details below, produce exactly two labeled sections and nothing else — no "
|
| 545 |
+
"markdown formatting on the section headers themselves, plain text after "
|
| 546 |
+
"each header:\n\n"
|
| 547 |
"WORK ORDER:\n"
|
| 548 |
+
"<a professional work order for the contractor, covering: (1) a clear issue "
|
| 549 |
+
"description, (2) priority level and SLA deadline, (3) 2-3 specific numbered "
|
| 550 |
+
"action instructions, (4) relevant context from the similar past case below "
|
| 551 |
+
"if any>\n\n"
|
| 552 |
"TENANT REPLY:\n"
|
| 553 |
+
"<a 2-4 sentence reply to the tenant, matching the tone guidance below; "
|
| 554 |
+
"do NOT mention internal systems, ticket IDs, or contractor names>\n\n"
|
| 555 |
+
f"TICKET DETAILS:\n"
|
| 556 |
f"- Building: {building_id}, Unit: {unit}\n"
|
| 557 |
f"- Category: {category}\n"
|
| 558 |
+
f"- Priority: {urgency} (SLA: respond within {sla})\n"
|
| 559 |
f"- Tenant report: {tenant_message}\n"
|
| 560 |
+
f"- Tenant tone: {tenant_tone} — {tone_instruction}\n"
|
| 561 |
f"{past_case}{contractor_info}"
|
| 562 |
)
|
| 563 |
+
# One call instead of two (the Round-1 speed fix) — but still enough tokens
|
| 564 |
+
# for real structured detail, not just a couple of terse sentences.
|
| 565 |
+
work_order, tenant_reply = "", ""
|
| 566 |
+
for partial in _generate_stream(prompt, max_new_tokens=340, temperature=0.6):
|
| 567 |
+
work_order, tenant_reply = _split_work_order_reply_partial(partial)
|
| 568 |
+
work_order = _inject_authoritative_priority(work_order, urgency, sla)
|
| 569 |
+
yield work_order, tenant_reply
|
| 570 |
+
# Final pass — same fallback text as before, now also triggered by a
|
| 571 |
+
# refusal/garbage generation (_is_valid_generation), not just an empty
|
| 572 |
+
# section. Only applied once streaming is done, so a legitimate answer
|
| 573 |
+
# isn't discarded mid-stream just because it's still short.
|
| 574 |
+
if not _is_valid_generation(work_order):
|
| 575 |
+
work_order = "Manual review required — auto-generation was inconclusive."
|
| 576 |
+
if not _is_valid_generation(tenant_reply):
|
| 577 |
+
tenant_reply = "Thank you for reporting this — a technician has been assigned."
|
| 578 |
+
yield work_order, tenant_reply
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
# ── Full pipeline: USER INPUT -> retrieval -> triage -> generation ────────────
|
| 582 |
+
def run_pipeline_stream(tenant_message, building_id="", unit="N/A"):
|
| 583 |
+
"""Generator version of the old run_pipeline: yields a growing result dict
|
| 584 |
+
as generation streams in, with generating=True until the final yield. The
|
| 585 |
+
no_match case now short-circuits before ever calling the LLM (previously
|
| 586 |
+
the full 340-token generation ran and was simply discarded by the caller
|
| 587 |
+
for a message that didn't match anything)."""
|
| 588 |
building_id = building_id.strip() or None
|
| 589 |
unit = unit.strip() or "N/A"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
rec = recommend_similar(tenant_message, top_k=3, building_id=building_id)
|
| 591 |
similar = rec["similar_tickets"]
|
| 592 |
contractor = rec["contractor_rank"][0] if rec["contractor_rank"] else None
|
| 593 |
+
# Primary triage signal is the fine-tuned classifier (test macro-F1: 0.975
|
| 594 |
+
# category / 0.766 urgency) -- it reads the current message's own content
|
| 595 |
+
# directly, rather than voting across nearest historical neighbors, which
|
| 596 |
+
# matters most for urgency: a couple of superficially similar but
|
| 597 |
+
# differently-severe past tickets can otherwise pull retrieval's vote
|
| 598 |
+
# toward the wrong SLA for a genuine emergency. Retrieval's own top-neighbor
|
| 599 |
+
# category/urgency is still shown as a cross-check (see fmt_triage below).
|
| 600 |
+
cc = rec.get("classifier_check")
|
| 601 |
+
if cc:
|
| 602 |
+
category = cc["category"]
|
| 603 |
+
urgency = URGENCY_FULL.get(cc["urgency"], cc["urgency"])
|
| 604 |
+
else:
|
| 605 |
+
category = similar[0]["category"] if similar else "Unknown"
|
| 606 |
+
urgency = similar[0]["urgency"] if similar else "P3 Standard (3-5d)"
|
| 607 |
+
tone = similar[0]["tenant_tone"] if similar else "polite-formal"
|
| 608 |
+
|
| 609 |
+
base = {
|
| 610 |
+
"triage": {"category": category, "urgency": urgency, "tone": tone},
|
| 611 |
"retrieval": rec,
|
| 612 |
"contractor": contractor,
|
|
|
|
|
|
|
| 613 |
"tenant_message": tenant_message,
|
| 614 |
+
"building_id": building_id or "Unknown",
|
| 615 |
"unit": unit,
|
| 616 |
}
|
|
|
|
|
|
|
| 617 |
|
| 618 |
+
if rec["status"] == "no_match":
|
| 619 |
+
yield {**base, "work_order": "", "tenant_reply": "", "generating": False}
|
| 620 |
+
return
|
| 621 |
|
| 622 |
+
# First yield: retrieval/triage already computed (a few ms), generation
|
| 623 |
+
# not started yet — the UI can show Triage + Similar Tickets immediately.
|
| 624 |
+
yield {**base, "work_order": "", "tenant_reply": "", "generating": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
|
| 626 |
+
work_order, tenant_reply = "", ""
|
| 627 |
+
for work_order, tenant_reply in generate_ticket_response_stream(
|
| 628 |
+
tenant_message, category, urgency, tone,
|
| 629 |
+
building_id or "Unknown", unit, similar, contractor,
|
| 630 |
+
):
|
| 631 |
+
yield {**base, "work_order": work_order, "tenant_reply": tenant_reply, "generating": True}
|
| 632 |
+
|
| 633 |
+
yield {**base, "work_order": work_order, "tenant_reply": tenant_reply, "generating": False}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
# ── Format helpers ─────────────────────────────────────────────────────────────
|
| 637 |
+
def fmt_triage(result):
|
| 638 |
+
t = result["triage"]
|
| 639 |
+
r = result["retrieval"]
|
| 640 |
+
code = t["urgency"][:2]
|
| 641 |
+
sla = SLA_MAP.get(code, "TBD")
|
| 642 |
+
color = URGENCY_HEX.get(code, "#6b7280")
|
| 643 |
+
conf = {"high": "✅ High", "medium": "⚠️ Medium", "low": "❓ Low"}.get(
|
| 644 |
+
r["confidence"], r["confidence"])
|
| 645 |
+
|
| 646 |
+
# States both signals as peers rather than "X agrees/disagrees with the
|
| 647 |
+
# headline above" -- this same formatter is also used by the cached Quick
|
| 648 |
+
# Starter path, where the headline is retrieval's pick rather than the
|
| 649 |
+
# classifier's, so the line must read correctly either way.
|
| 650 |
+
cc = r.get("classifier_check")
|
| 651 |
+
similar = r.get("similar_tickets") or []
|
| 652 |
+
if cc and similar:
|
| 653 |
+
retr_cat = similar[0]["category"]
|
| 654 |
+
retr_urg = similar[0]["urgency"][:2]
|
| 655 |
+
match_icon = "✅" if (cc["agrees_category"] and cc["agrees_urgency"]) else "⚠️"
|
| 656 |
+
retr_line = (f"**🤖 Fine-tuned classifier:** {cc['category']} · {cc['urgency']}"
|
| 657 |
+
f" | **📋 Nearest neighbors:** {retr_cat} · {retr_urg} {match_icon}")
|
| 658 |
+
else:
|
| 659 |
+
retr_line = "**🤖 Fine-tuned classifier:** _unavailable_"
|
| 660 |
+
|
| 661 |
+
pill = (
|
| 662 |
+
f'<div class="pp-priority-pill" style="background:{color}22; border:1.5px solid {color}; color:{color};">'
|
| 663 |
+
f"{URGENCY_EMOJI.get(code, '')} {t['category']} · {code} · SLA {sla}</div>"
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
return (
|
| 667 |
+
f'<div style="border-left:4px solid {color}; padding-left:12px;">\n\n'
|
| 668 |
+
f"{pill}\n\n"
|
| 669 |
+
f"**Tenant tone detected:** `{t['tone']}`\n\n"
|
| 670 |
+
f"**Retrieval confidence:** {conf} — {r['message']}\n\n"
|
| 671 |
+
f"{retr_line}"
|
| 672 |
+
f"\n\n</div>"
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def fmt_similar(result):
|
| 677 |
+
similar = result["retrieval"]["similar_tickets"]
|
| 678 |
+
if not similar:
|
| 679 |
+
return f"_No similar tickets found above the {MIN_SIM} similarity threshold._"
|
| 680 |
+
lines = []
|
| 681 |
+
for i, t in enumerate(similar, 1):
|
| 682 |
+
code = t["urgency"][:2]
|
| 683 |
+
color = URGENCY_HEX.get(code, "#6b7280")
|
| 684 |
+
pct = t["similarity"] * 100
|
| 685 |
+
bar = (f'<div class="pp-match-bar"><div class="pp-match-bar-fill" '
|
| 686 |
+
f'style="background:{color};width:{pct:.0f}%;"></div></div>')
|
| 687 |
+
lines.append(
|
| 688 |
+
f'<div style="border-left:3px solid {color}; padding-left:10px; margin-bottom:6px;">\n\n'
|
| 689 |
+
f"**[{i}] {t['similarity']:.1%} match** | {t['category']} | {code}\n{bar}\n"
|
| 690 |
+
f"> {t['raw_text'][:130]}...\n"
|
| 691 |
+
f"Resolved in **{t['resolution_hours']:.0f}h** · "
|
| 692 |
+
f"**${t['cost_usd']:.0f}** · _{t['resolution_notes']}_"
|
| 693 |
+
f"\n\n</div>"
|
| 694 |
)
|
| 695 |
+
c = result.get("contractor")
|
| 696 |
+
if c:
|
| 697 |
+
lines.append(
|
| 698 |
+
f"\n---\n**Recommended contractor:** {c['contractor_id']} "
|
| 699 |
+
f"({c['specialty']}) | {c['reason']}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
)
|
| 701 |
+
return "\n\n".join(lines)
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
def fmt_yelp_link(result):
|
| 705 |
+
if not result:
|
| 706 |
+
return ""
|
| 707 |
+
cat = result["triage"]["category"]
|
| 708 |
+
keyword = YELP_KEYWORDS.get(cat, "handyman")
|
| 709 |
+
url = f"https://www.yelp.com/search?find_desc={keyword}&find_loc=New+York%2C+NY"
|
| 710 |
+
return (
|
| 711 |
+
f'🔍 **Find a real contractor on Yelp:** '
|
| 712 |
+
f'[Search {cat} contractors in New York City →]({url})'
|
| 713 |
+
)
|
| 714 |
|
|
|
|
|
|
|
| 715 |
|
| 716 |
+
# ── Slack dispatch (+5% chatapp bonus) ─────────────────────────────────────────
|
| 717 |
def send_to_slack(result_json):
|
| 718 |
if not result_json:
|
| 719 |
return "Run the pipeline first."
|
| 720 |
if not SLACK_WEBHOOK:
|
| 721 |
+
return "Slack not configured — add a SLACK_WEBHOOK_URL secret in Space settings."
|
| 722 |
result = json.loads(result_json) if isinstance(result_json, str) else result_json
|
| 723 |
code = result["triage"]["urgency"][:2]
|
| 724 |
c = result.get("contractor")
|
| 725 |
+
con_text = f"{c['contractor_id']} ({c['specialty']}) — {c['reason']}" if c else "N/A"
|
| 726 |
sim_lines = "\n".join(
|
| 727 |
f"[{t['similarity']:.2f}] {t['category']} — {t['raw_text'][:70]}..."
|
| 728 |
for t in result["retrieval"]["similar_tickets"][:3]
|
| 729 |
) or "None"
|
| 730 |
payload = {"attachments": [{
|
| 731 |
"color": URGENCY_HEX.get(code, "#7289DA"),
|
| 732 |
+
"title": f"PropertyPilot — New Maintenance Ticket {URGENCY_EMOJI.get(code, '')} {code}",
|
| 733 |
"fields": [
|
| 734 |
+
{"title": "Location", "value": f"Building {result['building_id']} | Unit {result['unit']}", "short": True},
|
| 735 |
+
{"title": "Category", "value": result["triage"]["category"], "short": True},
|
| 736 |
+
{"title": "Tone", "value": result["triage"]["tone"], "short": True},
|
| 737 |
+
{"title": "SLA", "value": SLA_MAP.get(code, "TBD"), "short": True},
|
| 738 |
{"title": "Tenant Message", "value": result["tenant_message"][:300]},
|
| 739 |
{"title": "Similar Tickets", "value": sim_lines},
|
| 740 |
{"title": "Contractor", "value": con_text},
|
|
|
|
| 744 |
}]}
|
| 745 |
try:
|
| 746 |
r = requests.post(SLACK_WEBHOOK, json=payload, timeout=10)
|
| 747 |
+
if r.status_code == 200:
|
| 748 |
+
return "Dispatched to Slack ops channel!"
|
| 749 |
+
return f"Slack error {r.status_code}: {r.text[:200]}"
|
| 750 |
except Exception as e:
|
| 751 |
return f"Request failed: {e}"
|
| 752 |
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
# ── NYC-311 live feed (+5% live-data bonus) ────────────────────────────────────
|
| 756 |
+
NYC311_TYPES = (
|
| 757 |
"'HEAT/HOT WATER','PLUMBING','ELECTRIC','ELEVATOR',"
|
| 758 |
"'PAINT/PLASTER','WATER LEAK','DOOR/WINDOW','FLOORING/STAIRS'"
|
| 759 |
)
|
| 760 |
|
| 761 |
|
| 762 |
+
STATUS_COLOR = {"open": "#f59e0b", "closed": "#22c55e", "in progress": "#3b82f6",
|
| 763 |
+
"pending": "#f59e0b", "assigned": "#3b82f6"}
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
def _status_badge(s):
|
| 767 |
+
color = STATUS_COLOR.get((s or "").strip().lower(), "#6b7280")
|
| 768 |
+
return (f'<span style="background:{color}22;color:{color};padding:2px 10px;'
|
| 769 |
+
f'border-radius:999px;font-weight:600;">{s}</span>')
|
| 770 |
+
|
| 771 |
+
|
| 772 |
def fetch_nyc311():
|
| 773 |
url = (
|
| 774 |
"https://data.cityofnewyork.us/resource/erm2-nwe9.json"
|
| 775 |
f"?$limit=25&$order=created_date+DESC"
|
| 776 |
+
f"&$where=complaint_type+IN+({NYC311_TYPES})"
|
| 777 |
)
|
| 778 |
fetched_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 779 |
try:
|
|
|
|
| 785 |
"Borough": d.get("borough", "-").title(),
|
| 786 |
"Complaint": d.get("complaint_type", ""),
|
| 787 |
"Detail": d.get("descriptor", ""),
|
| 788 |
+
"Status": _status_badge(d.get("status", "")),
|
| 789 |
+
# NYC's own timestamp for when the complaint's status last changed —
|
| 790 |
+
# distinct from "Reported", so you can see which cases are actively moving.
|
| 791 |
"Last Updated": (d.get("resolution_action_updated_date")
|
| 792 |
or d.get("created_date", ""))[:16].replace("T", " "),
|
| 793 |
} for d in data]
|
| 794 |
+
status = f"Loaded {len(rows)} recent maintenance complaints · feed refreshed {fetched_at}"
|
| 795 |
+
return pd.DataFrame(rows), status
|
| 796 |
except Exception as e:
|
| 797 |
empty = pd.DataFrame(columns=["Reported", "Borough", "Complaint", "Detail", "Status", "Last Updated"])
|
| 798 |
+
return empty, f"Error: {e} · last attempted {fetched_at}"
|
| 799 |
|
| 800 |
|
| 801 |
+
# ── Quick Starters — instant, cached (no model calls) ─────────────────────────
|
| 802 |
+
def _render_from_cache(tenant_message, building_id, unit, cached, status_msg):
|
| 803 |
+
"""Shared by the Quick Starter buttons and the exact-text match in process():
|
| 804 |
+
retrieval is recomputed live (FAISS search only, no LLM — a few ms) so the
|
| 805 |
+
Similar Tickets / Triage panels are built the normal way; the cached
|
| 806 |
+
work_order/tenant_reply (the expensive LLM part) are reused as-is, which is
|
| 807 |
+
what makes this instant and deterministic no matter how the text arrived."""
|
| 808 |
+
rec = recommend_similar(tenant_message, top_k=3, building_id=building_id)
|
| 809 |
+
similar = rec["similar_tickets"]
|
| 810 |
+
contractor = rec["contractor_rank"][0] if rec["contractor_rank"] else None
|
| 811 |
+
cat = similar[0]["category"] if similar else "Unknown"
|
| 812 |
+
urg = similar[0]["urgency"] if similar else "P3 Standard (3-5d)"
|
| 813 |
+
tone = similar[0]["tenant_tone"] if similar else "polite-formal"
|
| 814 |
+
|
| 815 |
+
result = {
|
| 816 |
+
"triage": {"category": cat, "urgency": urg, "tone": tone},
|
| 817 |
+
"retrieval": rec,
|
| 818 |
+
"contractor": contractor,
|
| 819 |
+
"work_order": cached.get("work_order", ""),
|
| 820 |
+
"tenant_reply": cached.get("tenant_reply", ""),
|
| 821 |
+
"tenant_message": tenant_message,
|
| 822 |
+
"building_id": building_id,
|
| 823 |
+
"unit": unit,
|
| 824 |
+
}
|
| 825 |
+
result_json = json.dumps(result, default=str)
|
| 826 |
+
return (fmt_triage(result), fmt_similar(result),
|
| 827 |
+
result["work_order"], result["tenant_reply"],
|
| 828 |
+
status_msg, result_json, fmt_yelp_link(result))
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
def use_cached_quick_starter(category):
|
| 832 |
+
"""Quick Starter button handler — also fills msg/building/unit for transparency."""
|
| 833 |
+
cached = QUICK_STARTERS_CACHE.get(category)
|
| 834 |
+
if not cached:
|
| 835 |
+
return ("", "", "", "", "", "", "", "Quick Starter not available.", None, "")
|
| 836 |
+
|
| 837 |
+
tenant_message = cached["tenant_message"]
|
| 838 |
+
building_id = cached["building_id"]
|
| 839 |
+
unit = cached["unit"]
|
| 840 |
+
triage_md, similar_md, work_order, tenant_reply, status, result_json, yelp = _render_from_cache(
|
| 841 |
+
tenant_message, building_id, unit, cached, "Loaded from cache — instant!"
|
| 842 |
)
|
| 843 |
+
return (tenant_message, building_id, unit,
|
| 844 |
+
triage_md, similar_md, work_order, tenant_reply, status, result_json, yelp)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 845 |
|
|
|
|
|
|
|
| 846 |
|
| 847 |
+
# ── Autocomplete suggestions while typing ─────────────────────────────────────
|
| 848 |
+
N_SUGGESTIONS = 5
|
| 849 |
|
| 850 |
def get_suggestions(text):
|
| 851 |
text = (text or "").strip()
|
| 852 |
if not text or not AUTOCOMPLETE_PHRASES:
|
| 853 |
return [gr.update(value="", visible=False) for _ in range(N_SUGGESTIONS)] + [[]]
|
| 854 |
+
t = text.lower()
|
| 855 |
+
starts = [p for p in AUTOCOMPLETE_PHRASES if p.lower().startswith(t)]
|
| 856 |
contains = [p for p in AUTOCOMPLETE_PHRASES if t in p.lower() and p not in starts]
|
| 857 |
+
matches = (starts + contains)[:N_SUGGESTIONS]
|
| 858 |
+
labels = [(m[:55] + "...") if len(m) > 55 else m for m in matches]
|
| 859 |
+
updates = [
|
| 860 |
gr.update(value=labels[i], visible=True) if i < len(matches)
|
| 861 |
else gr.update(value="", visible=False)
|
| 862 |
for i in range(N_SUGGESTIONS)
|
|
|
|
| 864 |
return updates + [matches]
|
| 865 |
|
| 866 |
|
| 867 |
+
# ── Gradio process function (streaming) ────────────────────────────────────────
|
| 868 |
+
# Minimum gap between UI updates while a response is streaming in. Individual
|
| 869 |
+
# streamer chunks can arrive many times a second; without this, a 340-token
|
| 870 |
+
# response would push ~340 websocket updates. 120ms keeps the "typing" feel
|
| 871 |
+
# smooth without spamming the connection.
|
| 872 |
+
_STREAM_THROTTLE_S = 0.12
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
def process(tenant_message, building_id, unit):
|
| 876 |
+
if not tenant_message.strip():
|
| 877 |
+
yield "", "", "", "", "Please enter a tenant message.", None, ""
|
| 878 |
+
return
|
| 879 |
+
|
| 880 |
+
# Exact match against a cached Quick Starter (e.g. pasted rather than
|
| 881 |
+
# clicked) -> instant, deterministic cached result instead of a live LLM
|
| 882 |
+
# call, same as clicking the button.
|
| 883 |
+
cached = TEXT_TO_CACHED.get(tenant_message.strip())
|
| 884 |
+
if cached:
|
| 885 |
+
bid = building_id.strip() or cached["building_id"]
|
| 886 |
+
u = unit.strip() or cached["unit"]
|
| 887 |
+
yield _render_from_cache(
|
| 888 |
+
tenant_message.strip(), bid, u, cached,
|
| 889 |
+
"Matched a known ticket — instant cached result!",
|
| 890 |
+
)
|
| 891 |
+
return
|
| 892 |
+
|
| 893 |
+
last_yield_at = 0.0
|
| 894 |
+
for result in run_pipeline_stream(tenant_message, building_id, unit):
|
| 895 |
+
if result["retrieval"]["status"] == "no_match":
|
| 896 |
+
yield (
|
| 897 |
+
"## ⚠️ No Match\n\nInput doesn't resemble a maintenance request.",
|
| 898 |
+
"_No similar tickets found — similarity below threshold._",
|
| 899 |
+
"", "",
|
| 900 |
+
"⚠️ Input not recognized as a maintenance request. Please describe a specific issue.",
|
| 901 |
+
None, "",
|
| 902 |
+
)
|
| 903 |
+
return
|
| 904 |
+
|
| 905 |
+
now = time.monotonic()
|
| 906 |
+
is_final = not result["generating"]
|
| 907 |
+
if not is_final and (now - last_yield_at) < _STREAM_THROTTLE_S:
|
| 908 |
+
continue # skip this chunk, the next one (or the final yield) will catch up
|
| 909 |
+
last_yield_at = now
|
| 910 |
+
|
| 911 |
+
status = (
|
| 912 |
+
'✍️ Generating<span class="pp-typing-dots"><span></span><span></span><span></span></span> '
|
| 913 |
+
"(can take up to 1-2 minutes on free CPU hardware — this isn't stuck)"
|
| 914 |
+
if result["generating"] else "Done!"
|
| 915 |
+
)
|
| 916 |
+
result_json = json.dumps(result, default=str) if is_final else None
|
| 917 |
+
yield (fmt_triage(result), fmt_similar(result),
|
| 918 |
+
result["work_order"], result["tenant_reply"],
|
| 919 |
+
status, result_json, fmt_yelp_link(result))
|
| 920 |
+
|
| 921 |
+
|
| 922 |
+
# ── Theme + custom styling ──────────────────────────────────────────────────────
|
| 923 |
+
THEME = gr.themes.Soft(
|
| 924 |
+
primary_hue=gr.themes.colors.indigo,
|
| 925 |
+
secondary_hue=gr.themes.colors.amber,
|
| 926 |
+
neutral_hue=gr.themes.colors.slate,
|
| 927 |
+
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
|
| 928 |
+
).set(
|
| 929 |
+
body_background_fill="*neutral_50",
|
| 930 |
+
body_background_fill_dark="*neutral_950",
|
| 931 |
+
block_background_fill="white",
|
| 932 |
+
block_background_fill_dark="*neutral_800",
|
| 933 |
+
block_border_width="1px",
|
| 934 |
+
block_border_color="*neutral_200",
|
| 935 |
+
block_border_color_dark="*neutral_700",
|
| 936 |
+
block_radius="16px",
|
| 937 |
+
block_shadow="0 4px 14px rgba(15, 23, 42, 0.10)",
|
| 938 |
+
block_label_text_weight="600",
|
| 939 |
+
body_text_color="*neutral_800",
|
| 940 |
+
body_text_color_dark="*neutral_100",
|
| 941 |
+
button_large_radius="12px",
|
| 942 |
+
button_small_radius="10px",
|
| 943 |
+
button_primary_background_fill="*primary_600",
|
| 944 |
+
button_primary_background_fill_hover="*primary_700",
|
| 945 |
+
)
|
| 946 |
+
|
| 947 |
+
CSS = """
|
| 948 |
+
.pp-header {
|
| 949 |
+
background: linear-gradient(135deg, #4338ca 0%, #6d28d9 60%, #7c3aed 100%);
|
| 950 |
+
border-radius: 20px;
|
| 951 |
+
padding: 32px 36px;
|
| 952 |
+
margin-bottom: 20px;
|
| 953 |
+
box-shadow: 0 10px 25px rgba(67, 56, 202, 0.25);
|
| 954 |
}
|
| 955 |
+
.pp-header h1 { color: white !important; margin: 0 0 6px 0; font-size: 1.9rem; font-weight: 700; }
|
| 956 |
+
.pp-header p { color: rgba(255,255,255,0.92) !important; margin: 0; font-size: 1.02rem; }
|
| 957 |
+
.pp-badges { margin-top: 16px; display: flex; gap: 10px; flex-wrap: wrap; }
|
| 958 |
+
.pp-badge {
|
| 959 |
+
display: inline-block; background: rgba(255,255,255,0.16); color: white;
|
| 960 |
+
padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 500;
|
| 961 |
}
|
| 962 |
+
.pp-section-title { font-size: 1.05rem; font-weight: 700; margin: 0 0 10px 0; color: #1e1b4b; }
|
| 963 |
+
/* Target the actual heading element Gradio renders inside the wrapper div,
|
| 964 |
+
not the div itself -- an ::before on the block-level wrapper puts this
|
| 965 |
+
inline "▸" glyph before a block child, which forces it onto its own line
|
| 966 |
+
above the heading instead of sitting next to the text. Gradio also nests
|
| 967 |
+
an extra "prose" wrapper div between that outer div and the actual <h4>,
|
| 968 |
+
so even the immediate first child isn't the heading -- target h4 directly
|
| 969 |
+
via a descendant selector, regardless of how many wrapper divs sit
|
| 970 |
+
between the elem_classes div and the rendered heading. */
|
| 971 |
+
.pp-section-title-plain h4::before {
|
| 972 |
+
content: "▸"; color: var(--primary-600); margin-inline-end: 6px;
|
| 973 |
}
|
| 974 |
+
.pp-card { border-radius: 16px !important; }
|
| 975 |
+
#qs-grid button, #suggestion-row button { border-radius: 10px !important; }
|
| 976 |
+
#suggestion-row button { font-size: 0.82rem !important; }
|
| 977 |
+
footer { visibility: hidden; }
|
| 978 |
+
|
| 979 |
+
/* Dark mode — hard fallback rules (not just theme variables), so the page
|
| 980 |
+
background and card backgrounds definitely flip even if Gradio's own
|
| 981 |
+
variable system is scoped to a different ancestor than the one we toggle. */
|
| 982 |
+
.dark, .dark .gradio-container, body.dark {
|
| 983 |
+
background: #0b0b14 !important;
|
| 984 |
}
|
| 985 |
+
.dark .pp-header {
|
| 986 |
+
background: linear-gradient(135deg, #312e81 0%, #4c1d95 60%, #5b21b6 100%) !important;
|
| 987 |
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
}
|
| 989 |
+
.dark .pp-section-title { color: #c7d2fe !important; }
|
| 990 |
+
.dark .pp-badge { background: rgba(255,255,255,0.12) !important; }
|
| 991 |
+
.dark .pp-card, .dark .gr-group, .dark .block {
|
| 992 |
+
background: #16162a !important;
|
| 993 |
+
border-color: #2e2e4d !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 994 |
}
|
| 995 |
+
.dark .pp-card p, .dark .pp-card span, .dark .pp-card li,
|
| 996 |
+
.dark .gr-group p, .dark .gr-group span {
|
| 997 |
+
color: #e5e7eb !important;
|
| 998 |
}
|
| 999 |
+
/* gr.DataFrame (NYC-311 tab) and text inputs weren't covered above, so they
|
| 1000 |
+
stayed light-themed even after toggling to dark mode. */
|
| 1001 |
+
.dark table, .dark thead, .dark th, .dark td {
|
| 1002 |
+
background: #16162a !important;
|
| 1003 |
+
color: #e5e7eb !important;
|
| 1004 |
+
border-color: #2e2e4d !important;
|
| 1005 |
}
|
| 1006 |
+
.dark input, .dark textarea, .dark select {
|
| 1007 |
+
background: #16162a !important;
|
| 1008 |
+
color: #e5e7eb !important;
|
| 1009 |
+
border-color: #2e2e4d !important;
|
| 1010 |
}
|
| 1011 |
+
|
| 1012 |
+
/* ── Priority pill (fmt_triage headline) ──────────────────────────────────── */
|
| 1013 |
+
.pp-priority-pill {
|
| 1014 |
+
display: inline-block; border-radius: 999px; padding: 6px 16px;
|
| 1015 |
+
font-weight: 700; font-size: 1.1rem;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1016 |
}
|
| 1017 |
+
|
| 1018 |
+
/* ── Result-card hover lift ───────────────────────────────────────────────── */
|
| 1019 |
+
.pp-card { transition: box-shadow .2s ease, transform .2s ease; }
|
| 1020 |
+
.pp-card:hover { box-shadow: 0 8px 20px rgba(67,56,202,0.15); transform: translateY(-2px); }
|
| 1021 |
+
|
| 1022 |
+
/* ── Equal card heights within a row (Triage/Similar, Work Order/Reply) ────── */
|
| 1023 |
+
.pp-result-card-row { display: flex; align-items: stretch; }
|
| 1024 |
+
.pp-result-card { display: flex; flex-direction: column; flex: 1; }
|
| 1025 |
+
|
| 1026 |
+
/* ── Expand/Collapse toggle (client-side only, see items 1-2) ──────────────── */
|
| 1027 |
+
#wo-box textarea, #reply-box textarea { transition: height .2s ease, max-height .2s ease; }
|
| 1028 |
+
.pp-expanded textarea { max-height: none !important; height: 400px !important; }
|
| 1029 |
+
|
| 1030 |
+
/* ── Quick Starter selected state ─────────────────────────────────────────── */
|
| 1031 |
+
/* Solid, fixed-contrast background instead of a light tint (--primary-50) --
|
| 1032 |
+
the light tint put near-invisible light-on-light text in dark mode, since
|
| 1033 |
+
button text stays light-colored there. This reads correctly in both. */
|
| 1034 |
+
.qs-selected, .qs-selected:hover {
|
| 1035 |
+
border: 2px solid var(--primary-600) !important;
|
| 1036 |
+
background: var(--primary-600) !important;
|
| 1037 |
}
|
| 1038 |
+
.qs-selected, .qs-selected * { color: #ffffff !important; }
|
| 1039 |
+
|
| 1040 |
+
/* ── Similarity match bar (fmt_similar) ───────────────────────────────────── */
|
| 1041 |
+
.pp-match-bar { background: #e5e7eb; border-radius: 6px; height: 6px; width: 100%; margin: 4px 0; }
|
| 1042 |
+
.dark .pp-match-bar { background: #2e2e4d; }
|
| 1043 |
+
.pp-match-bar-fill { height: 100%; border-radius: 6px; }
|
| 1044 |
+
|
| 1045 |
+
/* ── Typing-dots loader (generation status) ───────────────────────────────── */
|
| 1046 |
+
.pp-typing-dots span {
|
| 1047 |
+
display: inline-block; width: 6px; height: 6px; margin: 0 2px;
|
| 1048 |
+
background: var(--primary-600); border-radius: 50%;
|
| 1049 |
+
animation: pp-bounce 1.4s infinite ease-in-out both;
|
| 1050 |
}
|
| 1051 |
+
.pp-typing-dots span:nth-child(1) { animation-delay: -0.32s; }
|
| 1052 |
+
.pp-typing-dots span:nth-child(2) { animation-delay: -0.16s; }
|
| 1053 |
+
@keyframes pp-bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
|
| 1054 |
"""
|
| 1055 |
|
| 1056 |
+
# ── Build UI ───────────────────────────────────────────────────────────────────
|
| 1057 |
+
with gr.Blocks(title="PropertyPilot v2", theme=THEME, css=CSS, fill_width=True) as demo:
|
| 1058 |
+
gr.HTML(
|
| 1059 |
+
f"""
|
| 1060 |
+
<div class="pp-header">
|
| 1061 |
+
<h1>🏢 PropertyPilot — AI Maintenance Assistant</h1>
|
| 1062 |
+
<p>Paste a tenant maintenance message and get instant triage, similar past
|
| 1063 |
+
tickets, a contractor work order, and a tone-matched tenant reply.</p>
|
| 1064 |
+
<div class="pp-badges">
|
| 1065 |
+
<span class="pp-badge">🗂️ {len(df):,} tickets analyzed</span>
|
| 1066 |
+
<span class="pp-badge">🧭 10 categories</span>
|
| 1067 |
+
<span class="pp-badge">⚡ FAISS-powered retrieval</span>
|
| 1068 |
+
<span class="pp-badge">🗽 Live NYC-311 feed</span>
|
| 1069 |
+
</div>
|
| 1070 |
+
</div>
|
| 1071 |
+
"""
|
| 1072 |
+
)
|
| 1073 |
+
with gr.Row():
|
| 1074 |
+
gr.Markdown("")
|
| 1075 |
+
dark_toggle = gr.Button("☀️ Day Mode", size="sm", variant="secondary", scale=0)
|
| 1076 |
+
dark_toggle.click(
|
| 1077 |
+
fn=None,
|
| 1078 |
+
js="""
|
| 1079 |
+
() => {
|
| 1080 |
+
// Toggle on every likely ancestor Gradio/our CSS might key off of,
|
| 1081 |
+
// so the whole page (background included) flips consistently.
|
| 1082 |
+
document.body.classList.toggle('dark');
|
| 1083 |
+
document.documentElement.classList.toggle('dark');
|
| 1084 |
+
document.querySelectorAll('.gradio-container').forEach(
|
| 1085 |
+
el => el.classList.toggle('dark')
|
| 1086 |
+
);
|
| 1087 |
+
// gr.DataFrame (NYC-311 tab) doesn't reliably inherit the
|
| 1088 |
+
// ancestor .dark class through CSS alone -- toggle it directly.
|
| 1089 |
+
document.querySelectorAll('table').forEach(el => el.classList.toggle('dark'));
|
| 1090 |
+
}
|
| 1091 |
+
""",
|
| 1092 |
+
)
|
| 1093 |
+
# Dark mode by default on page load.
|
| 1094 |
+
demo.load(
|
| 1095 |
+
fn=None,
|
| 1096 |
+
js="""
|
| 1097 |
+
() => {
|
| 1098 |
+
document.body.classList.add('dark');
|
| 1099 |
+
document.documentElement.classList.add('dark');
|
| 1100 |
+
document.querySelectorAll('table').forEach(el => el.classList.add('dark'));
|
| 1101 |
+
document.querySelectorAll('.gradio-container').forEach(
|
| 1102 |
+
el => el.classList.add('dark')
|
| 1103 |
+
);
|
| 1104 |
+
}
|
| 1105 |
+
""",
|
| 1106 |
+
)
|
| 1107 |
|
| 1108 |
with gr.Tabs():
|
| 1109 |
|
|
|
|
| 1110 |
with gr.Tab("🎫 Process Ticket"):
|
| 1111 |
+
with gr.Group(elem_classes=["pp-card"]):
|
| 1112 |
+
gr.Markdown("#### Quick Starters _(instant, cached — no waiting)_",
|
| 1113 |
+
elem_classes=["pp-section-title", "pp-section-title-plain"])
|
| 1114 |
+
qs_categories = sorted(QUICK_STARTERS_CACHE.keys())
|
| 1115 |
+
qs_btns = []
|
| 1116 |
+
with gr.Column(elem_id="qs-grid"):
|
| 1117 |
+
for row_start in range(0, len(qs_categories), 5):
|
| 1118 |
+
with gr.Row():
|
| 1119 |
+
for cat in qs_categories[row_start:row_start + 5]:
|
| 1120 |
+
label = f"{CATEGORY_ICON.get(cat, '🔧')} {cat}"
|
| 1121 |
+
qs_btns.append((cat, gr.Button(
|
| 1122 |
+
label, variant="secondary", size="sm",
|
| 1123 |
+
elem_id=f"qs-{cat.replace(' ', '-')}")))
|
| 1124 |
+
|
| 1125 |
+
with gr.Group(elem_classes=["pp-card"]):
|
| 1126 |
+
gr.Markdown("#### Describe the Issue", elem_classes=["pp-section-title", "pp-section-title-plain"])
|
| 1127 |
+
msg_box = gr.Textbox(
|
| 1128 |
+
label="Tenant Message", lines=4, show_label=False,
|
| 1129 |
+
placeholder="Paste the tenant's maintenance message here... "
|
| 1130 |
+
"(start typing to see suggestions)",
|
| 1131 |
+
)
|
| 1132 |
+
with gr.Row(elem_id="suggestion-row"):
|
| 1133 |
+
suggestion_btns = [
|
| 1134 |
+
gr.Button("", visible=False, size="sm", variant="secondary")
|
| 1135 |
+
for _ in range(N_SUGGESTIONS)
|
| 1136 |
+
]
|
| 1137 |
+
suggestions_state = gr.State([])
|
| 1138 |
+
with gr.Row():
|
| 1139 |
+
bldg_box = gr.Textbox(label="🏠 Building ID", value="B-01", scale=1)
|
| 1140 |
+
unit_box = gr.Textbox(label="🚪 Unit", value="1A", scale=1)
|
| 1141 |
+
with gr.Row():
|
| 1142 |
+
run_btn = gr.Button("✨ Analyze & Generate", variant="primary", size="lg", scale=3)
|
| 1143 |
+
clear_btn = gr.Button("🗑️ Clear", variant="secondary", size="lg", scale=1)
|
| 1144 |
+
|
| 1145 |
+
status_md = gr.Markdown("")
|
| 1146 |
+
|
| 1147 |
+
_EMPTY_HINT = "_Results will appear here after you analyze a ticket._"
|
| 1148 |
+
|
| 1149 |
+
with gr.Row(elem_classes=["pp-result-card-row"]):
|
| 1150 |
+
with gr.Group(elem_classes=["pp-card", "pp-result-card"]):
|
| 1151 |
+
gr.Markdown("#### Triage", elem_classes=["pp-section-title", "pp-section-title-plain"])
|
| 1152 |
+
triage_md = gr.Markdown(_EMPTY_HINT)
|
| 1153 |
+
with gr.Group(elem_classes=["pp-card", "pp-result-card"]):
|
| 1154 |
+
gr.Markdown("#### Similar Past Tickets", elem_classes=["pp-section-title", "pp-section-title-plain"])
|
| 1155 |
+
similar_md = gr.Markdown(_EMPTY_HINT)
|
| 1156 |
+
|
| 1157 |
+
with gr.Row(elem_classes=["pp-result-card-row"]):
|
| 1158 |
+
with gr.Group(elem_classes=["pp-card", "pp-result-card"]):
|
| 1159 |
+
gr.Markdown("#### Work Order _(for contractor)_", elem_classes=["pp-section-title", "pp-section-title-plain"])
|
| 1160 |
+
workorder_box = gr.Textbox(lines=7, interactive=False, show_label=False,
|
| 1161 |
+
show_copy_button=True, elem_id="wo-box")
|
| 1162 |
+
expand_wo_btn = gr.Button("⤢ Expand", size="sm", variant="secondary",
|
| 1163 |
+
elem_id="wo-expand-btn")
|
| 1164 |
+
# Pure client-side toggle -- a server round-trip (the old
|
| 1165 |
+
# gr.update(lines=...) approach) re-renders the component
|
| 1166 |
+
# and resets page scroll, which is jarring right after the
|
| 1167 |
+
# user scrolled down to reach this button.
|
| 1168 |
+
expand_wo_btn.click(fn=None, js="""
|
| 1169 |
+
() => {
|
| 1170 |
+
const box = document.getElementById('wo-box');
|
| 1171 |
+
const btn = document.getElementById('wo-expand-btn');
|
| 1172 |
+
const expanded = box.classList.toggle('pp-expanded');
|
| 1173 |
+
btn.textContent = expanded ? '⤡ Collapse' : '⤢ Expand';
|
| 1174 |
+
}
|
| 1175 |
+
""")
|
| 1176 |
+
with gr.Group(elem_classes=["pp-card", "pp-result-card"]):
|
| 1177 |
+
gr.Markdown("#### Tenant Reply _(tone-matched)_", elem_classes=["pp-section-title", "pp-section-title-plain"])
|
| 1178 |
+
reply_box = gr.Textbox(lines=7, interactive=False, show_label=False,
|
| 1179 |
+
show_copy_button=True, elem_id="reply-box")
|
| 1180 |
+
expand_reply_btn = gr.Button("⤢ Expand", size="sm", variant="secondary",
|
| 1181 |
+
elem_id="reply-expand-btn")
|
| 1182 |
+
expand_reply_btn.click(fn=None, js="""
|
| 1183 |
+
() => {
|
| 1184 |
+
const box = document.getElementById('reply-box');
|
| 1185 |
+
const btn = document.getElementById('reply-expand-btn');
|
| 1186 |
+
const expanded = box.classList.toggle('pp-expanded');
|
| 1187 |
+
btn.textContent = expanded ? '⤡ Collapse' : '⤢ Expand';
|
| 1188 |
+
}
|
| 1189 |
+
""")
|
| 1190 |
+
|
| 1191 |
+
result_state = gr.State(None)
|
| 1192 |
+
|
| 1193 |
+
with gr.Group(elem_classes=["pp-card"]):
|
| 1194 |
+
gr.Markdown("#### Find a Real Contractor (New York City)", elem_classes=["pp-section-title", "pp-section-title-plain"])
|
| 1195 |
+
yelp_md = gr.Markdown("_Analyze a ticket to get a Yelp contractor search link._")
|
| 1196 |
+
|
| 1197 |
+
with gr.Group(elem_classes=["pp-card"]):
|
| 1198 |
+
gr.Markdown("#### Ops Dispatch", elem_classes=["pp-section-title", "pp-section-title-plain"])
|
| 1199 |
+
with gr.Row():
|
| 1200 |
+
slack_btn = gr.Button("Send to Slack Ops Channel", variant="secondary")
|
| 1201 |
+
slack_md = gr.Markdown("")
|
| 1202 |
|
|
|
|
| 1203 |
for cat, btn in qs_btns:
|
| 1204 |
btn.click(
|
| 1205 |
+
fn=lambda _cat=cat: use_cached_quick_starter(_cat),
|
| 1206 |
+
outputs=[msg_box, bldg_box, unit_box, triage_md, similar_md,
|
| 1207 |
+
workorder_box, reply_box, status_md, result_state, yelp_md],
|
| 1208 |
)
|
| 1209 |
+
# Cosmetic only -- visually mark which Quick Starter was last
|
| 1210 |
+
# clicked, doesn't touch the cached-result logic above.
|
| 1211 |
+
btn.click(fn=None, js=f"""
|
| 1212 |
+
() => {{
|
| 1213 |
+
document.querySelectorAll('#qs-grid button').forEach(b => b.classList.remove('qs-selected'));
|
| 1214 |
+
document.getElementById('qs-{cat.replace(" ", "-")}').classList.add('qs-selected');
|
| 1215 |
+
}}
|
| 1216 |
+
""")
|
| 1217 |
+
|
| 1218 |
+
msg_box.input(
|
| 1219 |
+
fn=get_suggestions,
|
| 1220 |
inputs=[msg_box],
|
| 1221 |
outputs=suggestion_btns + [suggestions_state],
|
| 1222 |
)
|
| 1223 |
+
for i, sbtn in enumerate(suggestion_btns):
|
| 1224 |
+
sbtn.click(
|
| 1225 |
+
fn=lambda matches, _i=i: matches[_i] if _i < len(matches) else gr.update(),
|
| 1226 |
+
inputs=[suggestions_state],
|
| 1227 |
+
outputs=[msg_box],
|
| 1228 |
+
)
|
| 1229 |
|
| 1230 |
+
run_btn.click(
|
| 1231 |
+
fn=process,
|
| 1232 |
+
inputs=[msg_box, bldg_box, unit_box],
|
| 1233 |
+
outputs=[triage_md, similar_md, workorder_box, reply_box,
|
| 1234 |
+
status_md, result_state, yelp_md],
|
| 1235 |
)
|
| 1236 |
|
| 1237 |
+
slack_btn.click(fn=send_to_slack, inputs=[result_state], outputs=[slack_md])
|
| 1238 |
+
|
| 1239 |
+
clear_btn.click(
|
| 1240 |
+
fn=lambda: ("", "B-01", "1A", _EMPTY_HINT, _EMPTY_HINT, "", "", "", None, ""),
|
| 1241 |
+
outputs=[msg_box, bldg_box, unit_box, triage_md, similar_md,
|
| 1242 |
+
workorder_box, reply_box, status_md, result_state, yelp_md],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1243 |
)
|
| 1244 |
|
| 1245 |
+
with gr.Tab("🗽 NYC-311 Live Feed"):
|
| 1246 |
+
with gr.Group(elem_classes=["pp-card"]):
|
| 1247 |
+
gr.Markdown(
|
| 1248 |
+
"#### Real-Time Maintenance Complaints — New York City\n"
|
| 1249 |
+
"Live data from NYC Open Data (no API key required). "
|
| 1250 |
+
"Filtered to building maintenance categories.",
|
| 1251 |
+
elem_classes=["pp-section-title", "pp-section-title-plain"],
|
| 1252 |
+
)
|
| 1253 |
+
nyc_refresh = gr.Button("🔄 Refresh Feed", variant="primary")
|
| 1254 |
+
nyc_status = gr.Markdown("")
|
| 1255 |
+
nyc_table = gr.DataFrame(
|
| 1256 |
+
interactive=False,
|
| 1257 |
+
datatype=["str", "str", "str", "str", "markdown", "str"],
|
| 1258 |
+
)
|
| 1259 |
+
nyc_refresh.click(fn=fetch_nyc311, outputs=[nyc_table, nyc_status])
|
| 1260 |
+
demo.load(fn=fetch_nyc311, outputs=[nyc_table, nyc_status])
|
| 1261 |
+
|
| 1262 |
demo.launch()
|