CoolFace
Apppublic

kchen707/wedding-bundle-builder

sourceHugging Facemitupdated 5mo agoView on Hugging Face
0likes
timeline.py647 linesDownload Raw Back to root
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