Matanech commited on
Commit
a5b7b49
·
verified ·
1 Parent(s): 8570007

Target h4 directly for the section-title bullet (Gradio nests a prose wrapper div)

Browse files
Files changed (1) hide show
  1. app.py +1008 -615
app.py CHANGED
@@ -1,34 +1,41 @@
1
  """
2
- PropertyPilot v3 — AI Maintenance Assistant
3
- Design : Dark timeline / glass-card UI (property_pilot_app.py)
4
- Backend: Real FAISS + BGE embeddings + Qwen2.5 generation (propertypilot-v2)
5
- New : Fiverr contractor search link
6
- """
7
 
 
 
 
 
8
  import os
9
  import re
10
  import json
11
- import html
 
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
- from peft import PeftModel
24
- from collections import Counter
 
 
 
25
 
26
- # ── Configuration ─────────────────────────────────────────────────────────────
27
- HF_REPO = "propertypilot/property-pilot-tickets"
28
- HF_TOKEN = os.environ.get("HF_TOKEN") or None
29
  SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")
30
- GEN_MODEL = "Qwen/Qwen2.5-1.5B-Instruct"
31
- LORA_REPO = "propertypilot/property-pilot-generator"
32
 
33
  SLA_MAP = {
34
  "P1": "4 hours",
@@ -37,20 +44,14 @@ SLA_MAP = {
37
  "P4": "7-14 business days",
38
  }
39
 
40
- URGENCY_EMOJI = {"P1": "🔴", "P2": "🟠", "P3": "🔵", "P4": "🟢"}
41
- URGENCY_HEX = {"P1": "#ff6b6b", "P2": "#ffc078", "P3": "#7bdc9f", "P4": "#7bb3f0"}
42
-
43
- URGENCY_STYLE = {
44
- "P1": ("#3a1620", "#ff8fa3", "#5a2338"),
45
- "P2": ("#3a2a12", "#ffc078", "#5a4018"),
46
- "P3": ("#12301f", "#7bdc9f", "#1c4a30"),
47
- "P4": ("#16213a", "#7bb3f0", "#1e3a5a"),
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
- # ── Fiverr contractor search ──────────────────────────────────────────────────
73
- FIVERR_KEYWORDS = {
74
- "Electrical": "electrician+wiring+repair",
75
- "Plumbing": "plumber+pipe+leak+repair",
76
- "Hvac": "hvac+technician+air+conditioning+repair",
77
- "Appliance": "appliance+repair+technician",
78
- "Structural": "contractor+structural+repair+construction",
79
- "Pest": "pest+control+exterminator",
80
- "Security": "locksmith+door+lock+repair",
81
- "Noise": "noise+mediation+soundproofing",
82
- "Elevator": "elevator+lift+technician+repair",
83
- "Other": "handyman+home+repair",
 
 
84
  }
85
 
86
- def fiverr_url(category: str) -> str:
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 base model: {GEN_MODEL} + LoRA adapter: {LORA_REPO}...")
113
- gen_tokenizer = AutoTokenizer.from_pretrained(LORA_REPO, token=HF_TOKEN)
114
- gen_model = AutoModelForCausalLM.from_pretrained(GEN_MODEL, torch_dtype=torch.float16)
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
- # Quick Starters + autocomplete
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- TEXT_TO_CACHED = {v["tenant_message"].strip(): v for v in QUICK_STARTERS_CACHE.values()}
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
- # ── Recommender (FAISS + BGE) ─────────────────────────────────────────────────
 
137
  def _encode(text):
138
- prefix = cfg.get("query_prefix", "")
139
  return embed_model.encode(
140
- [prefix + text],
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 i >= 0 and s >= MIN_SIM]
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(float(sim), 3),
167
- "ticket_id": str(row["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
- counts = Counter(s["contractor_id"] for s in similar)
179
- contractor_rank = [
180
- {
181
- "contractor_id": cid,
182
- "count": cnt,
183
- "specialty": next(s["category"] for s in similar if s["contractor_id"] == cid),
184
- "reason": f"Handled {cnt} of the top-{len(similar)} similar tickets",
185
- }
186
- for cid, cnt in counts.most_common()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # ── LLM generation (Qwen2.5) ─────────────────────────────────────────────────
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
- out = gen_model.generate(
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
- return f"[Generation error: {e}]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def _clean(text):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  return text.strip().strip("*#").strip()
231
 
232
 
233
- def _split_wo_reply(raw):
234
- wo_m = _WO_HDR.search(raw)
235
- tr_m = _TR_HDR.search(raw)
236
- if wo_m and tr_m and tr_m.start() > wo_m.start():
237
- wo = _clean(raw[wo_m.end():tr_m.start()])
238
- tr = _clean(raw[tr_m.end():])
239
- elif tr_m:
240
- wo = _clean(raw[:tr_m.start()])
241
- tr = _clean(raw[tr_m.end():])
 
 
 
 
 
 
 
 
 
 
242
  else:
243
- wo = _clean(raw)
244
- tr = ""
245
- wo = wo or "Manual review required — auto-generation was inconclusive."
246
- tr = tr or "Thank you for reporting this — a technician has been assigned."
247
- return wo, tr
248
-
249
-
250
- def generate_ticket_response(tenant_message, category, urgency, tenant_tone,
251
- building_id, unit, similar_tickets=None, contractor=None):
252
- sla = SLA_MAP.get(urgency[:2], "TBD")
253
- tone_instruction = TONE_INSTRUCTIONS.get(tenant_tone, TONE_INSTRUCTIONS["polite-formal"])
254
- past_case = ""
 
 
 
255
  if similar_tickets:
256
  t = similar_tickets[0]
257
- past_case = (f"- Similar past case: {t['raw_text'][:120]}... "
258
- f"(resolved in {t['resolution_hours']:.0f}h, ${t['cost_usd']:.0f})\n")
259
- contractor_info = (
260
- f"- Suggested contractor: {contractor['contractor_id']} ({contractor['specialty']})\n"
261
- if contractor else ""
262
- )
 
 
 
 
263
  prompt = (
264
- "Produce exactly two labeled sections (no markdown on the headers themselves):\n\n"
 
 
 
265
  "WORK ORDER:\n"
266
- "<professional work order: issue description, priority/SLA, 2-3 action steps>\n\n"
 
 
 
267
  "TENANT REPLY:\n"
268
- "<2-4 sentence reply to tenant, matching tone; no internal IDs or contractor names>\n\n"
269
- f"DETAILS:\n"
 
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"- Tone: {tenant_tone} — {tone_instruction}\n"
275
  f"{past_case}{contractor_info}"
276
  )
277
- raw = _generate(prompt)
278
- return _split_wo_reply(raw)
279
-
280
- # ── Full pipeline ─────────────────────────────────────────────────────────────
281
- def run_pipeline(tenant_message, building_id="", unit="N/A"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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> &nbsp;·&nbsp;
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
- cat = similar[0]["category"] if similar else "Unknown"
425
- urg = similar[0]["urgency"] if similar else "P3 Standard (3-5d)"
426
- tone = similar[0]["tenant_tone"] if similar else "polite-formal"
427
- result = {
428
- "triage": {"category": cat, "urgency": urg, "tone": tone},
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def use_cached_quick_starter(category):
442
- cached = QUICK_STARTERS_CACHE.get(category)
443
- if not cached:
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
- cached = TEXT_TO_CACHED.get(tenant_message.strip())
457
- if cached:
458
- bid = building_id.strip() or cached.get("building_id", "B-01")
459
- u = unit.strip() or cached.get("unit", "1A")
460
- tl, wo, rp, lbl, status, rj = _render_from_cache(
461
- tenant_message.strip(), bid, u, cached, "Matched a known ticket — instant cached result!"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  )
463
- return tl, wo, rp, lbl, status, rj
464
-
465
- result = run_pipeline(tenant_message, building_id, unit)
466
- if result["retrieval"]["status"] == "no_match":
467
- return (
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
- tl, wo, rp, lbl, rj = _result_to_outputs(result)
475
- return tl, wo, rp, lbl, "Done!", rj
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 to Space secrets."
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 = f"{c['contractor_id']} ({c['specialty']}) — {c['reason']}" if c else "N/A"
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", "value": f"Building {result['building_id']} | Unit {result['unit']}", "short": True},
500
- {"title": "Category", "value": result["triage"]["category"], "short": True},
501
- {"title": "Tone", "value": result["triage"]["tone"], "short": True},
502
- {"title": "SLA", "value": SLA_MAP.get(code, "TBD"), "short": True},
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
- return "Dispatched to Slack ops channel!" if r.status_code == 200 else f"Slack error {r.status_code}: {r.text[:200]}"
 
 
513
  except Exception as e:
514
  return f"Request failed: {e}"
515
 
516
- # ── NYC-311 live feed ─────────────────────────────────────────────────────────
517
- _NYC311_TYPES = (
 
 
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+({_NYC311_TYPES})"
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
- return pd.DataFrame(rows), f"Loaded {len(rows)} complaints · refreshed {fetched_at}"
 
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
- def process_311_row(row_index):
550
- url = (
551
- "https://data.cityofnewyork.us/resource/erm2-nwe9.json"
552
- f"?$limit=25&$order=created_date+DESC"
553
- f"&$where=complaint_type+IN+({_NYC311_TYPES})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  )
555
- try:
556
- r = requests.get(url, timeout=15)
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 = text.lower()
577
- starts = [p for p in AUTOCOMPLETE_PHRASES if p.lower().startswith(t)]
578
  contains = [p for p in AUTOCOMPLETE_PHRASES if t in p.lower() and p not in starts]
579
- matches = (starts + contains)[:N_SUGGESTIONS]
580
- labels = [(m[:55] + "...") if len(m) > 55 else m for m in matches]
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
- def _make_fill_fn(idx):
590
- def fill(matches):
591
- if matches and idx < len(matches):
592
- return matches[idx]
593
- return gr.update()
594
- return fill
595
-
596
- # ── CSS ───────────────────────────────────────────────────────────────────────
597
- custom_css = """
598
- :root, .dark {
599
- --pp-bg: #0e0f11;
600
- --pp-panel: #17181b;
601
- --pp-card: #1d1f23;
602
- --pp-border: #2a2c31;
603
- --pp-accent-a: #7f77dd;
604
- --pp-accent-b: #1d9e75;
605
- --pp-text: #eaeaec;
606
- --pp-text-dim: #9a9ba0;
607
- }
608
- .gradio-container {
609
- background: var(--pp-bg) !important;
610
- color: var(--pp-text) !important;
611
- max-width: 1200px !important;
612
- }
613
- .pp-hero {
614
- background: linear-gradient(135deg, rgba(127,119,221,0.18), rgba(29,158,117,0.12));
615
- border: 1px solid var(--pp-border);
616
- border-radius: 16px;
617
- padding: 18px 22px;
618
- margin-bottom: 14px;
619
- }
620
- .pp-hero h1 { font-size: 20px; margin: 0 0 4px; font-weight: 600; }
621
- .pp-hero p { margin: 0; color: var(--pp-text-dim); font-size: 13px; }
622
- .pp-badges { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
623
- .pp-stat-badge {
624
- background: rgba(127,119,221,0.15);
625
- border: 1px solid rgba(127,119,221,0.3);
626
- color: #b9b3f2;
627
- border-radius: 999px;
628
- padding: 3px 10px;
629
- font-size: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
630
  }
631
- .pp-panel {
632
- background: var(--pp-panel) !important;
633
- border: 1px solid var(--pp-border) !important;
634
- border-radius: 14px !important;
 
 
635
  }
636
- .pp-quick-row button {
637
- background: var(--pp-card) !important;
638
- border: 1px solid var(--pp-border) !important;
639
- color: var(--pp-text) !important;
640
- border-radius: 999px !important;
641
- font-size: 12px !important;
 
 
 
 
 
642
  }
643
- .pp-quick-row button:hover { border-color: var(--pp-accent-a) !important; }
644
- .pp-primary button {
645
- background: linear-gradient(135deg, var(--pp-accent-a), var(--pp-accent-b)) !important;
646
- border: none !important;
647
- color: #fff !important;
648
- font-weight: 600 !important;
 
 
 
 
649
  }
650
- .pp-timeline { display: flex; flex-direction: column; gap: 10px; }
651
- .pp-bubble {
652
- align-self: flex-end;
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-bubble-meta { font-size: 10px; color: var(--pp-text-dim); text-transform: uppercase; letter-spacing: .04em; }
660
- .pp-bubble p { margin: 4px 0 0; font-size: 13px; line-height: 1.5; }
661
- .pp-node { display: flex; gap: 10px; }
662
- .pp-node-last { margin-top: -2px; }
663
- .pp-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 16px; flex-shrink: 0; }
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-label {
674
- font-size: 11px; color: var(--pp-text-dim);
675
- margin: 0 0 8px; text-transform: uppercase; letter-spacing: .04em;
676
  }
677
- .pp-label-purple { color: #b9b3f2; }
678
- .pp-label-pink { color: #f0a8c2; }
679
- .pp-badge {
680
- display: inline-block; font-size: 12px; font-weight: 600;
681
- padding: 4px 10px; border-radius: 999px; margin-right: 6px; margin-bottom: 4px;
 
682
  }
683
- .pp-sim-row {
684
- display: flex; gap: 10px; font-size: 12px;
685
- padding: 4px 0; border-top: 1px solid var(--pp-border);
 
686
  }
687
- .pp-sim-row:first-child { border-top: none; }
688
- .pp-sim-id { color: var(--pp-text-dim); min-width: 60px; }
689
- .pp-sim-text { flex: 1; }
690
- .pp-sim-score { color: #7bdc9f; min-width: 40px; text-align: right; }
691
- .pp-empty { color: var(--pp-text-dim); }
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
- .pp-contractor {
698
- font-size: 12px; color: #b9b3f2;
699
- margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--pp-border);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
700
  }
701
- .pp-fiverr-btn {
702
- display: inline-block;
703
- background: linear-gradient(135deg, #1dbf73, #17a060);
704
- color: #fff !important;
705
- border-radius: 999px;
706
- padding: 5px 16px;
707
- font-size: 12px;
708
- font-weight: 600;
709
- text-decoration: none !important;
710
- transition: opacity 0.15s;
 
 
711
  }
712
- .pp-fiverr-btn:hover { opacity: 0.85; }
 
 
713
  """
714
 
715
- # ── UI ─────────────────────────────────────────────────────────────────────────
716
- QUICK_LABELS = sorted(QUICK_STARTERS_CACHE.keys()) if QUICK_STARTERS_CACHE else list(CATEGORY_ICONS.keys())
717
-
718
- with gr.Blocks(
719
- title="PropertyPilot — AI Maintenance Assistant",
720
- css=custom_css,
721
- ) as demo:
722
-
723
- result_state = gr.State(None)
724
- suggestions_state = gr.State([])
725
-
726
- gr.HTML("""
727
- <div class="pp-hero">
728
- <h1>🏢 PropertyPilot — AI Maintenance Assistant</h1>
729
- <p>Paste a tenant maintenance message and get instant triage, similar past tickets,
730
- a contractor work order, a tone-matched tenant reply, and a Fiverr contractor search link.</p>
731
- <div class="pp-badges">
732
- <span class="pp-stat-badge">🗂️ 13,798 tickets analyzed</span>
733
- <span class="pp-stat-badge">🧭 10 categories</span>
734
- <span class="pp-stat-badge">⚡ FAISS-powered retrieval</span>
735
- <span class="pp-stat-badge">🤖 QLoRA finetuned Qwen2.5-1.5B</span>
736
- <span class="pp-stat-badge">🗽 Live NYC-311 feed</span>
737
- <span class="pp-stat-badge">🔗 Fiverr contractor search</span>
738
- </div>
739
- </div>
740
- """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
 
742
  with gr.Tabs():
743
 
744
- # ── Process Ticket tab ────────────────────────────────────────────────
745
  with gr.Tab("🎫 Process Ticket"):
746
- with gr.Row():
747
-
748
- # Left panel — inputs
749
- with gr.Column(scale=2, elem_classes=["pp-panel"]):
750
- gr.Markdown("**⚡ Quick Starters** *(instant, cached — no waiting)*")
751
- qs_btns = []
752
- with gr.Row(elem_classes=["pp-quick-row"]):
753
- for cat in QUICK_LABELS[:5]:
754
- lbl = f"{CATEGORY_ICONS.get(cat, '🔧')} {cat}"
755
- qs_btns.append((cat, gr.Button(lbl, size="sm")))
756
- with gr.Row(elem_classes=["pp-quick-row"]):
757
- for cat in QUICK_LABELS[5:]:
758
- lbl = f"{CATEGORY_ICONS.get(cat, '🔧')} {cat}"
759
- qs_btns.append((cat, gr.Button(lbl, size="sm")))
760
-
761
- msg_box = gr.Textbox(
762
- label="📝 Describe the Issue",
763
- placeholder="Paste the tenant's maintenance message here… (start typing for suggestions)",
764
- lines=5,
765
- )
766
-
767
- # Autocomplete suggestion buttons
768
- suggestion_btns = []
769
- with gr.Row():
770
- for _ in range(N_SUGGESTIONS):
771
- suggestion_btns.append(gr.Button("", visible=False, size="sm"))
772
-
773
- with gr.Row():
774
- bldg_box = gr.Textbox(label="🏠 Building ID", value="B-01")
775
- unit_box = gr.Textbox(label="🚪 Unit", value="1A")
776
-
777
- with gr.Row():
778
- analyze_btn = gr.Button(" Analyze & Generate", elem_classes=["pp-primary"])
779
- clear_btn = gr.Button("🗑️ Clear")
780
-
781
- status_md = gr.Markdown("")
782
-
783
- night_mode = gr.Checkbox(label="🌙 Night Mode", value=True)
784
-
785
- gr.Markdown("---\n**📡 Ops Dispatch**")
786
- slack_btn = gr.Button("📤 Send to Slack Ops Channel")
787
- slack_md = gr.Markdown("")
788
-
789
- # Right panel timeline output
790
- with gr.Column(scale=3, elem_classes=["pp-panel"]):
791
- triage_label = gr.Markdown("")
792
- timeline_html = gr.HTML(_EMPTY_TIMELINE)
793
- with gr.Accordion("📄 Raw work order / reply text", open=False):
794
- work_order_box = gr.Textbox(label="Work order", lines=6, interactive=False, show_copy_button=True)
795
- reply_box = gr.Textbox(label="Tenant reply", lines=4, interactive=False, show_copy_button=True)
796
-
797
- # Wire analyze
798
- analyze_btn.click(
799
- process,
800
- inputs=[msg_box, bldg_box, unit_box],
801
- outputs=[timeline_html, work_order_box, reply_box, triage_label, status_md, result_state],
802
- )
803
-
804
- # Wire clear
805
- clear_btn.click(
806
- clear_all,
807
- outputs=[msg_box, bldg_box, unit_box, timeline_html,
808
- work_order_box, reply_box, triage_label, status_md, result_state],
809
- )
810
-
811
- # Wire Slack
812
- slack_btn.click(send_to_slack, inputs=[result_state], outputs=[slack_md])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
813
 
814
- # Wire Quick Starters
815
  for cat, btn in qs_btns:
816
  btn.click(
817
- lambda c=cat: use_cached_quick_starter(c),
818
- outputs=[msg_box, bldg_box, unit_box, timeline_html,
819
- work_order_box, reply_box, triage_label, status_md, result_state],
820
  )
821
-
822
- # Wire autocomplete
823
- msg_box.change(
824
- get_suggestions,
 
 
 
 
 
 
 
825
  inputs=[msg_box],
826
  outputs=suggestion_btns + [suggestions_state],
827
  )
828
- for i, sb in enumerate(suggestion_btns):
829
- sb.click(_make_fill_fn(i), inputs=[suggestions_state], outputs=[msg_box])
 
 
 
 
830
 
831
- # Night mode toggle
832
- night_mode.change(
833
- None, [night_mode], None,
834
- js="(v) => { document.querySelector('.gradio-container').classList.toggle('dark', v); }",
 
835
  )
836
 
837
- # ── NYC-311 Live Feed tab ─────────────────────────────────────────────
838
- with gr.Tab("🗽 NYC-311 Live Feed"):
839
- gr.Markdown(
840
- "#### Real-Time Maintenance Complaints New York City\n"
841
- "Live data from NYC Open Data (no API key required). "
842
- "Filtered to building maintenance categories. "
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"&nbsp;&nbsp;|&nbsp;&nbsp;**📋 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()