kchen707/wedding-bundle-builder
0
1"""2Timeline module — combines:3 - Cell 21: compute_booking_timeline + LOADING_PHRASES + short-notice bias4 - Cell 35: build_timeline_tasks (template + LLM personalized)5 6These are pure logic / pure-LLM helpers used by both the matcher tab7and the Your Timeline tab in the Gradio UI.8"""9 10import json11import random # noqa: F401 (kept for parity with notebook even if unused here)12from datetime import date, timedelta13from typing import Optional14 15from clients import get_llm_client16from config import CHAT_MODEL17from matching import _parse_json_response18 19 20# ======================================================================21# Cell 21: booking timeline22# ======================================================================23 24CATEGORY_LEAD_TIMES = {25 "Venue": {"ideal": 12, "tight": 9, "minimum": 6},26 "Caterer/Food": {"ideal": 10, "tight": 6, "minimum": 3},27 "Photographer": {"ideal": 10, "tight": 6, "minimum": 3},28 "Florist": {"ideal": 8, "tight": 5, "minimum": 2},29 "Bakery/Wedding Cake": {"ideal": 5, "tight": 3, "minimum": 1},30 "DJ/Live Music": {"ideal": 9, "tight": 5, "minimum": 2},31 "Makeup Artist/Beauty": {"ideal": 6, "tight": 3, "minimum": 1},32 "Attire": {"ideal": 8, "tight": 5, "minimum": 3},33}34 35 36def _months_before(target: date, months: float) -> date:37 return target - timedelta(days=int(round(months * 30.44)))38 39 40def compute_booking_timeline(wedding_date: Optional[date],41 categories: list,42 today: Optional[date] = None) -> dict:43 """Build a per-category booking plan."""44 today = today or date.today()45 46 if wedding_date is None:47 return {48 "has_date": False, "wedding_date": None, "months_out": None,49 "overall_status": "undecided", "categories": {},50 "summary": "No wedding date set, recommendations are not timeline-constrained.",51 }52 53 days_out = (wedding_date - today).days54 months_out = days_out / 30.4455 56 per_cat = {}57 tight_cats = []58 unrealistic_cats = []59 60 for cat in categories:61 lt = CATEGORY_LEAD_TIMES.get(cat)62 if not lt:63 continue64 ideal_book_by = _months_before(wedding_date, lt["ideal"])65 latest_book_by = _months_before(wedding_date, lt["minimum"])66 tight_book_by = _months_before(wedding_date, lt["tight"])67 68 if months_out >= lt["ideal"]:69 status = "on_track"70 message = f"You have time, book by ~{ideal_book_by.strftime('%b %Y')}."71 elif months_out >= lt["tight"]:72 status = "book_now"73 message = f"Book soon, ideally before {tight_book_by.strftime('%b %Y')}."74 elif months_out >= lt["minimum"]:75 status = "tight"76 message = (f"Tight timeline, most {cat.lower()} vendors book "77 f"{lt['ideal']}+ months out. We've prioritized vendors "78 f"who work with shorter lead times.")79 tight_cats.append(cat)80 else:81 status = "unrealistic"82 message = (f"Very tight, typical {cat.lower()} lead time is "83 f"{lt['minimum']}+ months. Limited availability likely; "84 f"we're showing vendors most likely to accommodate.")85 unrealistic_cats.append(cat)86 87 per_cat[cat] = {88 "ideal_book_by": ideal_book_by,89 "latest_book_by": latest_book_by,90 "status": status, "message": message,91 }92 93 if unrealistic_cats:94 overall = "unrealistic"95 summary = (f"Your wedding is {months_out:.1f} months away. "96 f"We've prioritized vendors who work with short-notice bookings.")97 elif tight_cats:98 overall = "tight"99 summary = (f"Your wedding is {months_out:.1f} months away. "100 f"Book these as soon as possible.")101 else:102 overall = "comfortable"103 summary = (f"Your wedding is {months_out:.1f} months away, you have "104 f"comfortable booking windows for all selected categories.")105 106 return {107 "has_date": True, "wedding_date": wedding_date,108 "months_out": round(months_out, 1), "overall_status": overall,109 "categories": per_cat, "summary": summary,110 }111 112 113def tight_timeline_category_set(timeline: dict) -> set:114 return {cat for cat, info in timeline.get("categories", {}).items()115 if info["status"] in ("tight", "unrealistic")}116 117 118# ======================================================================119# Loading phrases (cell 21)120# ======================================================================121 122LOADING_PHRASES = {123 "profile_parsing": [124 "Reading your wedding profile, noting the non-negotiables...",125 "Parsing your vibe, translating 'moody garden chic' into search terms...",126 "Decoding what you actually mean by 'elegant'...",127 "Reading between the emoji, committing your preferences to memory...",128 ],129 "image_analysis": [130 "Studying your inspiration photos, extracting color palette and mood...",131 "Looking at your Pinterest pulls, is that eucalyptus or olive branch?",132 "Pulling style DNA from your mood board...",133 "Squinting at your aesthetic, mapping it to vendor keywords...",134 ],135 "timeline_check": [136 "Mapping your wedding date against vendor booking windows...",137 "Checking the calendar, who needs booking first?",138 "Doing the months-until math so you don't have to...",139 "Running your date through the booking-urgency calculator...",140 ],141 "advice": [142 "Drafting your budget allocation and trade-offs...",143 "Thinking through your money, where should each dollar go?",144 "Deciding which categories deserve a splurge and which a deal...",145 "Weighing the tradeoffs your guest count forces on the budget...",146 ],147 "filtering": [148 "Applying hard filters, budget, category, capacity...",149 "Narrowing the field, goodbye, wrong-fit vendors...",150 "Dismissing vendors who can't meet your constraints...",151 "Filtering 1,100 San Diego vendors down to actual candidates...",152 ],153 "query_expansion": [154 "Expanding the search, asking the same question three different ways...",155 "Generating search angles, style, logistics, cultural fit...",156 "Reformulating your request into diverse search queries...",157 "Thinking about your wedding from three different angles at once...",158 ],159 "retrieval": [160 "Searching 1,100+ San Diego vendors, casting a wide net...",161 "Scanning the vendor database, skimming reviews and portfolios...",162 "Asking every San Diego florist if they fit your vibe...",163 "Comparing your profile against every vendor embedding...",164 "Sifting the haystack for the right needles...",165 ],166 "reranking": [167 "Re-ranking the top candidates, separating great from good-enough...",168 "The LLM is being picky on your behalf, top 15 per category...",169 "Grading the finalists, scoring each vendor against your preferences...",170 "Cross-examining the top 15 in each category...",171 "Second-guessing the first-round rankings...",172 ],173 "explanation": [174 "Writing match explanations, why this vendor, for you?",175 "Drafting 'why it's a match' notes, grounded in real vendor data...",176 "Composing the 'meet your vendor' blurbs...",177 "Pulling specific reasons from each vendor's reviews and style tags...",178 ],179 "assembly": [180 "Assembling your bundle, almost ready to unwrap...",181 "Bow-tying the recommendations, last touches...",182 "Laying out your bundle in its final form...",183 "Polishing the pearls, a moment more...",184 ],185}186 187 188STAGE_WEIGHTS = {189 "profile_parsing": 0.03,190 "image_analysis": 0.08,191 "timeline_check": 0.02,192 "advice": 0.12,193 "filtering": 0.03,194 "query_expansion": 0.08,195 "retrieval": 0.10,196 "reranking": 0.35,197 "explanation": 0.15,198 "assembly": 0.04,199}200 201 202STAGE_ROTATION_COUNT = {203 "profile_parsing": 1,204 "image_analysis": 2,205 "timeline_check": 1,206 "advice": 2,207 "filtering": 1,208 "query_expansion": 2,209 "retrieval": 3,210 "reranking": 4,211 "explanation": 3,212 "assembly": 1,213}214 215 216def loading_phrase_for(stage: str, index: int = 0) -> str:217 variants = LOADING_PHRASES.get(stage, [f"Working on: {stage}..."])218 if not variants:219 return f"Working on: {stage}..."220 return variants[index % len(variants)]221 222 223def cumulative_progress_for(stage: str,224 completed_stages: list,225 within_stage_progress: float = 1.0) -> float:226 total = 0.0227 for s in completed_stages:228 total += STAGE_WEIGHTS.get(s, 0.05)229 total += STAGE_WEIGHTS.get(stage, 0.05) * within_stage_progress230 return min(total, 1.0)231 232 233# ======================================================================234# Short-notice vendor heuristic (cell 21)235# ======================================================================236 237SHORT_NOTICE_KEYWORDS = [238 "flexible", "short notice", "last minute", "last-minute",239 "quick turnaround", "rush", "accommodating", "available",240 "fit us in", "open schedule", "fast response", "easy to book",241 "responsive", "quick", "jump in",242]243 244 245def short_notice_score(vendor) -> float:246 """Heuristic in [0, 1]. Higher = more likely to accommodate tight timelines."""247 score = 0.0248 249 try:250 review_count = float(vendor.get("num_reviews", 0) or 0)251 if review_count < 30:252 score += 0.30253 elif review_count < 100:254 score += 0.20255 elif review_count < 300:256 score += 0.10257 except (TypeError, ValueError):258 pass259 260 blob_parts = []261 for field in ("sample_review_snippets", "description",262 "services_offered", "embedding_text",263 "review_snippets", "reviews_summary"):264 val = vendor.get(field) if hasattr(vendor, "get") else None265 if isinstance(val, str):266 blob_parts.append(val.lower())267 blob = " ".join(blob_parts)268 if blob:269 hits = sum(1 for kw in SHORT_NOTICE_KEYWORDS if kw in blob)270 score += min(hits * 0.10, 0.40)271 272 tier = vendor.get("pricing_tier") if hasattr(vendor, "get") else None273 if isinstance(tier, str):274 t = tier.lower()275 if "$$$$" in t or "luxury" in t or "premium" in t:276 score += 0.0277 elif "$$$" in t or "mid" in t:278 score += 0.10279 elif "$$" in t or "budget" in t or "affordable" in t:280 score += 0.20281 282 if hasattr(vendor, "get"):283 if vendor.get("is_active") in (True, "True", "true", 1, "1"):284 score += 0.10285 286 return min(score, 1.0)287 288 289def apply_short_notice_bias(candidates, bias_weight: float = 0.25):290 """Re-rank candidates to favor short-notice-friendly vendors."""291 if not candidates:292 return candidates293 adjusted = []294 for m in candidates:295 base = float(m.get("similarity", 0.0) or 0.0)296 base_norm = max(0.0, min(1.0, (base + 1.0) / 2.0)) if base < 0 else min(1.0, base)297 sn = short_notice_score(m["vendor"])298 adj = (1 - bias_weight) * base_norm + bias_weight * sn299 m2 = dict(m)300 m2["_adjusted_score"] = adj301 m2["_short_notice_score"] = sn302 adjusted.append(m2)303 adjusted.sort(key=lambda d: d["_adjusted_score"], reverse=True)304 return adjusted305 306 307# ======================================================================308# Cell 35: Timeline task generation (template + LLM hybrid)309# ======================================================================310 311TEMPLATE_TASKS = [312 ("set_budget", None, 14,313 "Finalize your wedding budget",314 "Lock in your total spend before any vendor outreach."),315 ("build_guest_list", None, 13,316 "Draft your guest list",317 "Your headcount drives venue size, catering cost, and invitation count."),318 ("book_venue", "Venue", 12,319 "Book your venue, {vendor_name}",320 "Venues book furthest out. Once signed, you can set your wedding date in stone."),321 ("book_photographer", "Photographer", 11,322 "Book your photographer, {vendor_name}",323 "Top-tier photographers book 10-12 months out, especially for peak months."),324 ("send_save_the_dates", None, 7,325 "Send save-the-dates",326 "Standard is 6-8 months before for local, 8-12 months for destination."),327 ("book_caterer", "Caterer/Food", 10,328 "Book your caterer, {vendor_name}",329 "Schedule a tasting with them 3-4 months before the wedding."),330 ("book_dj_band", "DJ/Live Music", 9,331 "Book your DJ or band, {vendor_name}",332 "Popular DJs book 6-9 months out. Discuss playlist preferences and do-not-play lists."),333 ("book_florist", "Florist", 8,334 "Book your florist, {vendor_name}",335 "Florists need lead time to source seasonal flowers."),336 ("buy_wedding_attire", "Attire", 8,337 "Order wedding attire, {vendor_name}",338 "Allow time for fittings, alterations, and any delivery delays."),339 ("book_officiant", None, 8,340 "Book an officiant",341 "Confirm they're licensed to perform weddings in San Diego County."),342 ("book_hair_makeup", "Makeup Artist/Beauty", 7,343 "Book your hair & makeup artist, {vendor_name}",344 "Schedule a trial run 6-8 weeks before the wedding."),345 ("order_cake", "Bakery/Wedding Cake", 6,346 "Order your wedding cake, {vendor_name}",347 "Cake tastings usually happen 3-4 months before the wedding."),348 ("send_invitations", None, 2,349 "Send formal invitations",350 "Standard is 6-8 weeks before the wedding, with RSVP deadline ~4 weeks out."),351 ("plan_ceremony", None, 4,352 "Plan ceremony order and vows",353 "Share the flow with your officiant for their rehearsal prep."),354 ("apply_marriage_license", None, 2,355 "Apply for a marriage license",356 "California licenses are valid for 90 days. Both partners must appear in person."),357 ("finalize_seating", None, 1.5,358 "Finalize the seating chart",359 "Most couples finalize this after RSVPs close."),360 ("confirm_headcount", "Caterer/Food", 1,361 "Confirm final headcount with your caterer, {vendor_name}",362 "Most caterers require a final count 2-3 weeks out."),363 ("write_vows", None, 1,364 "Write your vows",365 "Give yourself time to revise, first drafts are rarely the final."),366 ("final_dress_fitting", "Attire", 0.75,367 "Attend final dress/suit fitting, {vendor_name}",368 "Bring the shoes and undergarments you'll wear on the day."),369 ("timeline_to_vendors", None, 0.5,370 "Send day-of timeline to all vendors",371 "A shared timeline prevents vendor coordination gaps on the day."),372 ("rehearsal_dinner", None, 0.15,373 "Host rehearsal dinner",374 "Traditionally the night before the wedding."),375 ("pack_emergency_kit", None, 0.1,376 "Pack a day-of emergency kit",377 "Safety pins, stain remover, snacks, backup phone charger, basic first-aid."),378 ("delegate_responsibilities", None, 0.1,379 "Delegate day-of responsibilities",380 "Assign someone to hold rings, tip vendors, and grab personal items at the end."),381]382 383 384def _compute_deadline(wedding_date, months_before):385 if wedding_date is None:386 return None387 days = int(round(float(months_before) * 30.44))388 return wedding_date - timedelta(days=days)389 390 391def _format_deadline(d):392 if d is None:393 return ""394 return d.strftime("%b %d, %Y")395 396 397def _deadline_status(deadline, today=None):398 if deadline is None:399 return None400 today = today or date.today()401 days_left = (deadline - today).days402 if days_left < 0:403 return "overdue"404 if days_left <= 30:405 return "urgent"406 if days_left <= 90:407 return "soon"408 if days_left <= 180:409 return "on_track"410 return "comfortable"411 412 413def _resolve_vendor_label(label_template, category, vendor_override_map, bundle):414 """Fill {vendor_name} preferring override > bundle top_pick > generic."""415 if "{vendor_name}" not in label_template:416 return label_template417 if category is None:418 return label_template.replace(", {vendor_name}", "")419 if vendor_override_map and category in vendor_override_map:420 override = vendor_override_map[category]421 if override and override.strip():422 return label_template.format(vendor_name=override.strip())423 if bundle and category in bundle:424 top = bundle[category].get("top_pick")425 if top and top.get("vendor") is not None:426 name = top["vendor"].get("business_name")427 if name:428 return label_template.format(vendor_name=name)429 return label_template.replace(", {vendor_name}", "")430 431 432def build_template_tasks(preferences: dict,433 bundle: dict = None,434 vendor_override_map: dict = None,435 wedding_date=None) -> list:436 """Expand TEMPLATE_TASKS for this couple, with calendar deadlines."""437 selected_categories = set(preferences.get("categories_needed") or [])438 tasks = []439 440 for task_id, category, months, label_template, note in TEMPLATE_TASKS:441 if category is not None and category not in selected_categories:442 continue443 444 rendered = _resolve_vendor_label(445 label_template, category, vendor_override_map, bundle446 )447 deadline = _compute_deadline(wedding_date, months)448 status = _deadline_status(deadline)449 450 tasks.append({451 "id": task_id,452 "category": category,453 "milestone_months": months,454 "label": label_template,455 "rendered_label": rendered,456 "supports_vendor_swap": category is not None and "{vendor_name}" in label_template,457 "note": note,458 "source": "template",459 "deadline": deadline,460 "deadline_str": _format_deadline(deadline),461 "status": status,462 "is_overdue": status == "overdue",463 })464 465 return tasks466 467 468PERSONALIZED_TASKS_PROMPT = """You are generating 2-3 EXTRA wedding planning tasks that are SPECIFIC to this couple's profile, tasks that a generic wedding checklist would miss.469 470You have their profile (budget, guests, categories, style, cultural, dietary) and their selected vendor bundle.471 472Focus on tasks that wouldn't appear on a standard checklist. Good candidates:473 - Cultural tradition prep (e.g. "Source a mandap for Hindu ceremony", "Confirm kosher certification with caterer")474 - Dietary logistics specific to their requirements ("Confirm vegetarian menu with caterer and get tasting")475 - Style-specific prep ("Source vintage rentals for barn aesthetic")476 - Cultural vendor coordination ("Arrange henna artist for the mehndi night")477 - San Diego specifics where relevant (permits, coastal weather backup)478 479DO NOT generate:480 - Generic tasks already on every checklist (book photographer, send invites, etc.)481 - Vague tasks ("plan the wedding")482 - Tasks unrelated to the profile specifics483 484Return ONLY a valid JSON object:485{486 "tasks": [487 {488 "id": "<short_snake_case_id>",489 "label": "<imperative task in 4-10 words>",490 "milestone_months": <number, months before wedding>,491 "note": "<one practical sentence, why it matters or how to approach it>",492 "rationale": "<short reason this is personalized to THIS couple, not generic>"493 }494 ]495}496 497Rules:498- Generate 2-3 tasks only499- milestone_months must be one of: 10, 8, 6, 4, 2, 1, 0.5 (we'll bucket these)500- Keep labels concrete and actionable501- Ground in the profile, every task's rationale must reference a specific profile fact502 503Return ONLY the JSON object. No markdown fences, no preamble."""504 505 506def generate_personalized_tasks(preferences: dict,507 bundle: dict = None,508 wedding_date=None) -> list:509 """Ask the LLM for 2-3 personalized tasks. Returns [] on failure."""510 has_hooks = (511 (preferences.get("cultural_preferences") or [])512 or (preferences.get("dietary_requirements") or [])513 or (preferences.get("style_keywords") or [])514 )515 if not has_hooks:516 return []517 518 bundle_summary = {}519 if bundle:520 for cat, matches in bundle.items():521 top = matches.get("top_pick")522 if top and top.get("vendor") is not None:523 bundle_summary[cat] = top["vendor"].get("business_name", "(name unavailable)")524 525 payload = {526 "profile": {527 "budget_total": preferences.get("budget_total"),528 "guest_count": preferences.get("guest_count"),529 "categories_needed": preferences.get("categories_needed"),530 "style_keywords": preferences.get("style_keywords"),531 "cultural_preferences": preferences.get("cultural_preferences"),532 "dietary_requirements": preferences.get("dietary_requirements"),533 "location": preferences.get("location_preference"),534 },535 "bundle_top_picks": bundle_summary,536 }537 538 try:539 client = get_llm_client()540 resp = client.chat.completions.create(541 model=CHAT_MODEL,542 messages=[543 {"role": "system", "content": PERSONALIZED_TASKS_PROMPT},544 {"role": "user", "content": json.dumps(payload, default=str)},545 ],546 temperature=0.4,547 )548 parsed = _parse_json_response(resp.choices[0].message.content)549 if not parsed or "tasks" not in parsed:550 return []551 552 normalized = []553 for t in (parsed.get("tasks") or [])[:3]:554 if not isinstance(t, dict):555 continue556 if "id" not in t or "label" not in t or "milestone_months" not in t:557 continue558 task_id = f"llm_{t['id']}".lower().replace(" ", "_")[:60]559 try:560 months = float(t["milestone_months"])561 except (TypeError, ValueError):562 months = 4563 deadline = _compute_deadline(wedding_date, months)564 status = _deadline_status(deadline)565 normalized.append({566 "id": task_id,567 "category": None,568 "milestone_months": months,569 "label": t["label"],570 "rendered_label": t["label"],571 "supports_vendor_swap": False,572 "note": t.get("note", ""),573 "source": "llm",574 "rationale": t.get("rationale", ""),575 "deadline": deadline,576 "deadline_str": _format_deadline(deadline),577 "status": status,578 "is_overdue": status == "overdue",579 })580 return normalized581 582 except Exception as e:583 print(f" Personalized task generation failed: {e}")584 return []585 586 587def attach_deadlines_to_cached_tasks(cached_tasks, wedding_date):588 """Re-attach deadlines if the wedding date changed after caching."""589 if not cached_tasks or wedding_date is None:590 return cached_tasks591 updated = []592 for t in cached_tasks:593 t2 = dict(t)594 deadline = _compute_deadline(wedding_date, t["milestone_months"])595 status = _deadline_status(deadline)596 t2["deadline"] = deadline597 t2["deadline_str"] = _format_deadline(deadline)598 t2["status"] = status599 t2["is_overdue"] = status == "overdue"600 updated.append(t2)601 return updated602 603 604def build_timeline_tasks(preferences, bundle=None, vendor_override_map=None,605 wedding_date=None, cached_llm_tasks=None):606 """Top-level entry: produce the full task list for this couple."""607 template_tasks = build_template_tasks(608 preferences, bundle, vendor_override_map, wedding_date=wedding_date609 )610 llm_tasks = attach_deadlines_to_cached_tasks(cached_llm_tasks or [], wedding_date)611 612 all_tasks = template_tasks + llm_tasks613 all_tasks.sort(key=lambda t: -t["milestone_months"])614 return all_tasks615 616 617MILESTONE_BUCKETS = [618 ("12+ months out", lambda m: m >= 12),619 ("9-12 months out", lambda m: 9 <= m < 12),620 ("6-9 months out", lambda m: 6 <= m < 9),621 ("3-6 months out", lambda m: 3 <= m < 6),622 ("1-3 months out", lambda m: 1 <= m < 3),623 ("Final month", lambda m: 0.2 <= m < 1),624 ("Week of wedding", lambda m: m < 0.2),625]626 627 628def bucket_tasks_by_milestone(tasks):629 """Group tasks into milestone buckets, with overdue pulled to top."""630 overdue = [t for t in tasks if t.get("is_overdue")]631 non_overdue = [t for t in tasks if not t.get("is_overdue")]632 buckets = []633 if overdue:634 buckets.append(("⚠ Overdue, act immediately", overdue))635 for bucket_name, predicate in MILESTONE_BUCKETS:636 matching = [t for t in non_overdue if predicate(t["milestone_months"])]637 if matching:638 buckets.append((bucket_name, matching))639 return buckets640 641 642def build_vendor_booking_schedule(tasks):643 """Vendor-category tasks sorted by deadline (earliest first)."""644 vendor_tasks = [t for t in tasks if t["category"] is not None and t["deadline"]]645 vendor_tasks.sort(key=lambda t: t["deadline"])646 return vendor_tasks647 