diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py index 3c65e07..d9881ed 100644 --- a/backend/app/api/rag.py +++ b/backend/app/api/rag.py @@ -10,8 +10,14 @@ from app.core.security import get_current_user from app.core.database import get_db from app.services.campus_food import REVIEW_ONLY_STATUSES, build_food_recommendations, load_campus_food -from app.services.bedrock import generate_text -from app.services.runway import build_runway_forecast, derive_pool_obligations +from app.services.ai_guardrails import ( + EXTERNAL_FOOD_APP_TERMS, + GroundingError, + ai_response_metadata, + validate_grounded_advice, +) +from app.services.bedrock import generate_json, generate_text +from app.services.runway import build_runway_forecast, cycle_bounds, derive_pool_obligations from app.services.subscriptions import detect_recurring_subscriptions from app.services.wellness import current_meal_gap_hours, meal_signal_events @@ -25,6 +31,165 @@ class RagReq(BaseModel): spent_today: float +def _rounded_rupees(*values: float | int | None) -> list[float]: + result = [] + for value in values: + if value is None: + continue + try: + result.append(round(float(value), 2)) + except (TypeError, ValueError): + continue + return result + + +def _trusted_food_prompt_options(options: list[dict]) -> list[dict]: + return [ + { + "venue": option.get("venue_name"), + "item": option.get("item_name"), + "price_rs": round((option.get("price", 0) or 0) / 100), + "trust": option.get("trust_badge"), + "why": option.get("why"), + } + for option in options + ] + + +def _trusted_food_entities(options: list[dict]) -> list[str]: + entities: list[str] = [] + for option in options: + entities.extend([str(option.get("venue_name") or ""), str(option.get("item_name") or "")]) + return entities + + +def _trusted_food_rupees(options: list[dict]) -> list[float]: + return [round((option.get("price", 0) or 0) / 100, 2) for option in options] + + +def _campus_food_option(option: dict | None) -> dict | None: + if not option: + return None + return { + "venue": option.get("venue_name"), + "item": option.get("item_name"), + "price_rs": round((option.get("price", 0) or 0) / 100), + "trust": option.get("trust_badge"), + "why": option.get("why"), + } + + +def _doc_amount_paise(item: dict | None) -> int: + if not item: + return 0 + for key in ("amount", "amount_paise"): + amount = item.get(key) + if isinstance(amount, int) and not isinstance(amount, bool) and amount > 0: + return amount + return 0 + + +def _doc_datetime(value) -> datetime.datetime | None: + if isinstance(value, datetime.datetime): + if value.tzinfo is not None: + return value.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return value + if isinstance(value, str): + try: + parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + return parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return parsed + except ValueError: + return None + return None + + +def _is_debit_transaction(txn: dict) -> bool: + if txn.get("is_income") is True: + return False + return str(txn.get("direction") or "").lower() != "credit" + + +def _build_local_campus_insight( + *, + spend_7: float, + remaining: float, + days_left: int, + safe_daily: float, + last_food_hours: float, + upcoming_commitments: float, + upcoming_commitment_count: int, + food_option: dict | None, +) -> dict: + weekly_daily_pace = spend_7 / 7 if spend_7 > 0 else 0 + pace_ratio = weekly_daily_pace / safe_daily if safe_daily > 0 else 0 + commitment_ratio = upcoming_commitments / max(remaining, 1) if remaining > 0 else 0 + + if remaining <= 0: + headline = "Runway needs attention" + action = "Pause flexible spends today and use essentials until the cycle resets." + why = f"Your current cycle balance is Rs {remaining:.0f}, with {days_left} days left." + focus = "runway" + elif upcoming_commitments > 0 and commitment_ratio >= 0.25: + headline = "Commitments ahead" + action = f"Keep today near Rs {safe_daily:.0f} and avoid taking on new fixed costs." + why = f"Rs {upcoming_commitments:.0f} is scheduled across {upcoming_commitment_count} upcoming commitment{'s' if upcoming_commitment_count != 1 else ''}." + focus = "commitments" + elif safe_daily > 0 and pace_ratio >= 1.25: + headline = "Pace is running high" + action = f"Keep flexible spend near Rs {safe_daily:.0f} today before adding anything new." + why = f"This week's pace is about Rs {weekly_daily_pace:.0f}/day against a safe Rs {safe_daily:.0f}/day." + focus = "spend" + elif last_food_hours > 10: + headline = "Routine check due" + action = "Log a quick meal check-in and keep the next campus spend simple." + why = f"The last routine food signal was {last_food_hours:.0f} hours ago." + focus = "routine" + else: + headline = "Campus plan is steady" + action = f"Keep today close to Rs {safe_daily:.0f} and review commitments before any large spend." + why = f"You have Rs {remaining:.0f} left across {days_left} cycle days." + focus = "steady" + + signals = [ + { + "label": "Runway", + "value": f"Rs {safe_daily:.0f}/day" if safe_daily > 0 else "Set budget", + "detail": f"Rs {remaining:.0f} left" if remaining > 0 else "No cycle buffer", + "tone": "watch" if safe_daily < 120 else "steady", + }, + { + "label": "Spend pace", + "value": f"Rs {weekly_daily_pace:.0f}/day" if weekly_daily_pace > 0 else "No spend", + "detail": "Above safe/day" if pace_ratio >= 1.25 else "Inside range", + "tone": "watch" if pace_ratio >= 1.25 else "steady", + }, + { + "label": "Commitments", + "value": f"Rs {upcoming_commitments:.0f}" if upcoming_commitments > 0 else "Clear", + "detail": f"{upcoming_commitment_count} due soon" if upcoming_commitment_count else "Next 7 days", + "tone": "watch" if upcoming_commitments > max(safe_daily * 2, 500) else "steady", + }, + { + "label": "Routine", + "value": f"{last_food_hours:.0f}h ago" if last_food_hours > 0 else "No signal", + "detail": "Check in" if last_food_hours > 10 else "Recent enough", + "tone": "watch" if last_food_hours > 10 else "steady", + }, + ] + + return { + "headline": headline, + "next_action": action, + "why": why, + "summary": f"{action} {why}", + "focus": focus, + "signals": signals, + "food_option": food_option, + } + + @router.post("/food-rag") async def get_food_recommendation(req: RagReq, user_id: str = Depends(get_current_user)): db = get_db() @@ -36,35 +201,92 @@ async def get_food_recommendation(req: RagReq, user_id: str = Depends(get_curren campus_foods = load_campus_food() fallback = build_local_recommendation(req, campus_foods) + ranked_options = fallback.get("ranked_options", []) + daily_budget = max(0, req.remaining_budget / max(req.days_left, 1)) + facts_used = [ + f"days_left={req.days_left}", + f"remaining_budget_rs={req.remaining_budget:.0f}", + f"spent_today_rs={req.spent_today:.0f}", + f"daily_food_runway_rs={daily_budget:.0f}", + ] + if fallback.get("item"): + item = fallback["item"] + facts_used.append( + f"recommended_food={item.get('item_name')} at {item.get('venue_name')} for Rs {round((item.get('price', 0) or 0) / 100):.0f}" + ) if not settings.BEDROCK_ENABLED: - return {**fallback, "source": "local_fallback"} + return { + **fallback, + "source": "local_fallback", + **ai_response_metadata(source="local_fallback", facts_used=facts_used, fallback_reason="bedrock_disabled"), + } try: + trusted_options = _trusted_food_prompt_options(ranked_options[:5]) prompt = f""" - You are an AI financial assistant for a college student. - The student has {req.days_left} days left in their cycle, Rs {req.remaining_budget:.0f} remaining, - and has spent Rs {req.spent_today:.0f} today. - - Trusted, budget-aware campus food options are JSON objects where price is in paise: - {json.dumps(fallback.get("ranked_options", [])[:5], indent=2)} - - Analyze their runway and suggest exactly one cost-effective food option from the list. - Do not recommend any food option that is not present in the trusted options above. - Provide a very short, encouraging 2-sentence response telling them what to eat and why it fits their tight budget. +You are PocketBuddy's grounded student food advisor. + +Backend facts: +- Days left in cycle: {req.days_left} +- Remaining budget: Rs {req.remaining_budget:.0f} +- Spent today: Rs {req.spent_today:.0f} +- Food runway for today: Rs {daily_budget:.0f} + +Trusted campus food options only: +{json.dumps(trusted_options, ensure_ascii=True)} + +Hard rules: +- Return advice only, not a financial fact or guarantee. +- Use only the exact prices and trusted campus options above. +- Do not mention delivery apps, live prices, medical claims, stress diagnosis, or any food option outside the list. +- If you mention a number, it must appear in Backend facts or Trusted campus food options. +- Keep it useful for a student who wants to avoid overspending without skipping food. + +Return ONLY valid JSON: +{{"recommendation":"one or two concise sentences"}} """ - recommendation = generate_text(prompt, max_tokens=150, temperature=0.25) + result = generate_json(prompt, max_tokens=180, temperature=0.15) + recommendation = validate_grounded_advice( + result.get("recommendation"), + allowed_rupee_values=_rounded_rupees(req.remaining_budget, req.spent_today, daily_budget) + + _trusted_food_rupees(ranked_options[:5]), + allowed_time_values=[req.days_left], + allowed_entities=_trusted_food_entities(ranked_options[:5]), + require_entity=bool(ranked_options), + forbidden_terms=EXTERNAL_FOOD_APP_TERMS, + max_chars=300, + ) return { + **fallback, "recommendation": recommendation, "source": "bedrock", "fallback": fallback["recommendation"], + **ai_response_metadata(source="bedrock", facts_used=facts_used), } + except GroundingError as exc: + logger.warning("Bedrock food recommendation was ungrounded; using local fallback: %s", exc) + return { + **fallback, + "source": "local_fallback", + "bedrock_error": "ungrounded_response", + **ai_response_metadata( + source="local_fallback", + facts_used=facts_used, + fallback_reason="ungrounded_response", + ), + } except Exception as exc: logger.warning("Bedrock recommendation failed; using local fallback: %s", exc) - return {**fallback, "source": "local_fallback", "bedrock_error": "unavailable"} + return { + **fallback, + "source": "local_fallback", + "bedrock_error": "unavailable", + **ai_response_metadata(source="local_fallback", facts_used=facts_used, fallback_reason="bedrock_unavailable"), + } def build_local_recommendation(req: RagReq, campus_foods: list[dict]) -> dict: @@ -117,13 +339,23 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): profile = await db.profiles.find_one({"_id": user_id}) - # Basic spending stats now = datetime.datetime.utcnow() since_7 = now - datetime.timedelta(days=7) - cursor = db.transactions.find({"user_id": user_id, "created_at": {"$gte": since_7}}) + cycle_start, cycle_end = cycle_bounds(int((profile or {}).get("cycle_start_day") or 1), now) + cursor = db.transactions.find({"user_id": user_id, "created_at": {"$gte": min(since_7, cycle_start)}}) txns = await cursor.to_list(length=500) - spend_7 = sum(t.get("amount", 0) for t in txns) / 100 - food_txns = [t for t in txns if t.get("category") == "food"] + debit_txns = [t for t in txns if _is_debit_transaction(t)] + spend_7 = sum( + _doc_amount_paise(t) + for t in debit_txns + if (_doc_datetime(t.get("created_at")) or now) >= since_7 + ) / 100 + cycle_spend = sum( + _doc_amount_paise(t) + for t in debit_txns + if cycle_start <= (_doc_datetime(t.get("created_at")) or now) < cycle_end + ) / 100 + food_txns = [t for t in debit_txns if t.get("category") == "food"] checkins = await db.checkin_logs.find({ "user_id": user_id, "created_at": {"$gte": since_7}, @@ -132,70 +364,189 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): last_food_hours = current_meal_gap_hours(now, meal_events, default=0.0) last_food_source = meal_events[-1]["source"] if meal_events else None - remaining = (profile.get("monthly_allowance", 0) / 100) if profile else 0 + allowance = ((profile or {}).get("monthly_allowance", 0) or 0) / 100 + remaining = max(0, allowance - cycle_spend) + days_left = max(1, (cycle_end.date() - now.date()).days) + safe_daily = remaining / days_left if days_left > 0 else remaining + + subscriptions = await db.subscriptions.find( + {"user_id": user_id, "is_active": {"$ne": False}} + ).to_list(length=100) + commitment_window_end = now + datetime.timedelta(days=7) + upcoming_subscriptions = [ + sub + for sub in subscriptions + if (due_at := _doc_datetime(sub.get("next_debit_date"))) and now <= due_at <= commitment_window_end + ] + upcoming_commitments = sum(_doc_amount_paise(sub) for sub in upcoming_subscriptions) / 100 + upcoming_commitment_count = len([sub for sub in upcoming_subscriptions if _doc_amount_paise(sub) > 0]) + + facts_used = [ + f"last_7_day_spend_rs={spend_7:.0f}", + f"remaining_cycle_budget_rs={remaining:.0f}", + f"cycle_days_left={days_left}", + f"safe_daily_spend_rs={safe_daily:.0f}", + f"upcoming_commitments_7d_rs={upcoming_commitments:.0f}", + f"last_food_signal_hours={last_food_hours:.1f}", + ] + fallback_reason = "bedrock_disabled" if not settings.BEDROCK_ENABLED else "bedrock_unavailable" + + cursor_food = db.campus_food.find({ + "status": {"$nin": list(REVIEW_ONLY_STATUSES)} + }) + campus_foods = await cursor_food.to_list(length=1000) + if not campus_foods: + campus_foods = load_campus_food() + safe_budget_paise = 15_000 + if profile: + safe_budget_paise = int((profile.get("monthly_allowance", 0) or 0) / 30) + ranked_foods = build_food_recommendations( + campus_foods, + now=datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + safe_food_budget_paise=safe_budget_paise, + meal_gap_hours=last_food_hours, + limit=5, + ) + trusted_options = _trusted_food_prompt_options(ranked_foods[:5]) + food_option = _campus_food_option(ranked_foods[0] if ranked_foods else None) + if food_option: + facts_used.append( + f"top_campus_food={food_option.get('item')} at {food_option.get('venue')} for Rs {food_option.get('price_rs')}" + ) + fallback_insight = _build_local_campus_insight( + spend_7=spend_7, + remaining=remaining, + days_left=days_left, + safe_daily=safe_daily, + last_food_hours=last_food_hours, + upcoming_commitments=upcoming_commitments, + upcoming_commitment_count=upcoming_commitment_count, + food_option=food_option, + ) # Try Bedrock if settings.BEDROCK_ENABLED: try: - cursor_food = db.campus_food.find({ - "status": {"$nin": list(REVIEW_ONLY_STATUSES)} - }) - campus_foods = await cursor_food.to_list(length=1000) - if not campus_foods: - campus_foods = load_campus_food() - safe_budget_paise = 15_000 - if profile: - safe_budget_paise = int((profile.get("monthly_allowance", 0) or 0) / 30) - ranked_foods = build_food_recommendations( - campus_foods, - now=datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), - safe_food_budget_paise=safe_budget_paise, - meal_gap_hours=last_food_hours, - limit=5, - ) + weekly_daily_pace = spend_7 / 7 if spend_7 > 0 else 0 + prompt = f"""You are PocketBuddy's campus intelligence layer for Indian college students. +Choose the single most useful campus nudge from these backend facts. - prompt = f"""You are PocketBuddy, a student budget and routine assistant for Indian college students. Student context: -- Spent Rs {spend_7:.0f} in last 7 days -- Remaining budget: Rs {remaining:.0f} -- Last food payment/check-in signal: {last_food_hours:.0f} hours ago -- Trusted campus food options: {json.dumps([{"venue": f.get("venue_name"), "item": f.get("item_name"), "price_rs": f.get("price", 0)//100, "why": f.get("why"), "trust": f.get("trust_badge")} for f in ranked_foods[:5]], indent=None)} - -Generate exactly 2 concise, specific, actionable sentences as a campus financial intelligence summary. -Be direct and mention real numbers. -Describe only budget and routine signals; do not infer illness, stress, sleep quality, or medical risk. -If you mention food, cite only trusted campus food options above. -No emojis. No preamble.""" - - text = generate_text(prompt, max_tokens=120, temperature=0.2) - if text: +- Current cycle days left: {days_left} +- Current cycle budget left: Rs {remaining:.0f} +- Safe daily spend for this cycle: Rs {safe_daily:.0f} +- Spent in last 7 days: Rs {spend_7:.0f} +- Last 7-day daily spend pace: Rs {weekly_daily_pace:.0f} +- Upcoming fixed commitments in next 7 days: Rs {upcoming_commitments:.0f} across {upcoming_commitment_count} item(s) +- Last routine food signal: {last_food_hours:.0f} hours ago +- Trusted campus food options, only if routine/food is truly the strongest signal: {json.dumps(trusted_options[:3], ensure_ascii=True)} + +Hard rules: +- Give student-life advice only; do not diagnose stress, sleep, anxiety, burnout, or health. +- Treat all prices, spend, commitment, food-gap, and budget values as backend facts. Do not invent or estimate new numbers. +- Choose from focus values only: runway, spend, commitments, routine, steady. +- Do not cite a food option unless the routine signal is clearly the strongest issue. +- Do not mention any delivery app, live price, bank claim, or guarantee. +- Be specific enough that the student can act in under 30 seconds. + +Return ONLY valid JSON: +{{"focus":"runway|spend|commitments|routine|steady","headline":"under 6 words","next_action":"one concise action sentence","why":"one concise reason sentence","summary":"one sentence combining the action and reason"}} +""" + + allowed_rupees = _rounded_rupees( + spend_7, + remaining, + safe_daily, + weekly_daily_pace, + upcoming_commitments, + safe_budget_paise / 100, + ) + _trusted_food_rupees(ranked_foods[:5]) + allowed_times = [7, 30, days_left, round(last_food_hours, 1)] + allowed_entities = _trusted_food_entities(ranked_foods[:5]) + result = generate_json(prompt, max_tokens=160, temperature=0.15) + focus = str(result.get("focus") or fallback_insight["focus"]).strip().lower() + if focus not in {"runway", "spend", "commitments", "routine", "steady"}: + focus = fallback_insight["focus"] + headline = validate_grounded_advice( + result.get("headline"), + allowed_rupee_values=allowed_rupees, + allowed_time_values=allowed_times, + allowed_plain_values=[upcoming_commitment_count], + allowed_entities=allowed_entities, + forbidden_terms=EXTERNAL_FOOD_APP_TERMS, + max_chars=80, + max_sentences=1, + ) + next_action = validate_grounded_advice( + result.get("next_action"), + allowed_rupee_values=allowed_rupees, + allowed_time_values=allowed_times, + allowed_plain_values=[upcoming_commitment_count], + allowed_entities=allowed_entities, + require_entity=focus == "routine" and last_food_hours > 10 and bool(ranked_foods), + forbidden_terms=EXTERNAL_FOOD_APP_TERMS, + max_chars=180, + max_sentences=1, + ) + why = validate_grounded_advice( + result.get("why"), + allowed_rupee_values=allowed_rupees, + allowed_time_values=allowed_times, + allowed_plain_values=[upcoming_commitment_count], + allowed_entities=allowed_entities, + forbidden_terms=EXTERNAL_FOOD_APP_TERMS, + max_chars=180, + max_sentences=1, + ) + summary = validate_grounded_advice( + result.get("summary"), + allowed_rupee_values=allowed_rupees, + allowed_time_values=allowed_times, + allowed_plain_values=[upcoming_commitment_count], + allowed_entities=allowed_entities, + forbidden_terms=EXTERNAL_FOOD_APP_TERMS, + max_chars=360, + max_sentences=2, + ) + if summary: return { - "summary": text, + "summary": summary, + "headline": headline, + "next_action": next_action, + "why": why, + "focus": focus, + "signals": fallback_insight["signals"], + "food_option": food_option if focus == "routine" else None, "source": "bedrock", "spend_7d": spend_7, + "remaining_budget": remaining, + "days_left": days_left, + "safe_daily": round(safe_daily), + "upcoming_commitments": upcoming_commitments, + "safe_food_budget": round(safe_budget_paise / 100), "last_food_hours": round(last_food_hours, 1), "last_food_signal_source": last_food_source, + **ai_response_metadata(source="bedrock", facts_used=facts_used), } + except GroundingError as exc: + fallback_reason = "ungrounded_response" + logger.warning("Bedrock campus-intel was ungrounded; using local fallback: %s", exc) except Exception as exc: + fallback_reason = "bedrock_unavailable" logger.warning("Bedrock campus-intel failed: %s", exc) - # Local fallback - routine_parts = [] - if spend_7 > 0: - routine_parts.append(f"You've spent Rs {spend_7:.0f} in the last 7 days.") - if last_food_hours > 8: - routine_parts.append(f"Your last meal signal was {last_food_hours:.0f} hours ago; log a meal or use a trusted campus option if needed.") - elif last_food_hours > 0: - routine_parts.append(f"Last meal signal was {last_food_hours:.0f} hours ago.") - if remaining > 0: - routine_parts.append(f"Rs {remaining:.0f} remaining in your current cycle.") - summary = " ".join(routine_parts) if routine_parts else "Start logging transactions to activate campus intelligence." return { - "summary": summary, + **fallback_insight, "source": "local_fallback", "spend_7d": spend_7, + "remaining_budget": remaining, + "days_left": days_left, + "safe_daily": round(safe_daily), + "upcoming_commitments": upcoming_commitments, + "safe_food_budget": round(safe_budget_paise / 100), "last_food_hours": round(last_food_hours, 1), "last_food_signal_source": last_food_source, + **ai_response_metadata(source="local_fallback", facts_used=facts_used, fallback_reason=fallback_reason), } @@ -287,6 +638,21 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)): stress_band = (forecast.get("projection") or {}).get("stress_band") or {} expected_days = int((stress_band.get("expected") or {}).get("days_until_broke") or broke_days) stress_days = int((stress_band.get("stress") or {}).get("days_until_broke") or broke_days) + days_before_cycle_end = max(0, days_left - broke_days) + shortfall_percent = round(shortfall_prob * 100) + facts_used = [ + f"cycle_days_left={days_left}", + f"days_until_broke={broke_days}", + f"expected_runway_days={expected_days}", + f"stress_runway_days={stress_days}", + f"shortfall_probability_percent={shortfall_percent}", + f"safe_daily_spend_rs={safe_daily}", + f"projected_daily_spend_rs={projected_daily}", + f"remaining_cycle_budget_rs={remaining}", + f"food_cap_rs={food_cap}", + f"next_action={next_action_title}", + ] + fallback_reason = "bedrock_disabled" if not settings.BEDROCK_ENABLED else "bedrock_unavailable" # Build local fallback if status == "setup_required": @@ -328,7 +694,7 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)): - Cycle days left: {days_left} days - Safe daily spend limit: Rs {safe_daily} - Current daily spend pace (EWMA): Rs {projected_daily} -- Forecast status: {status.upper()} +- Forecast status: {str(status or "steady").upper()} - Shortfall probability: {shortfall_prob * 100:.0f}% - Expected runway: {expected_days} days - Stress-case runway: {stress_days} days @@ -338,14 +704,36 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)): - Food pace: Rs {food_pace}/day - Suggested food cap: Rs {food_cap}/day - Decision engine summary: {decision_summary} -- Next best action: {next_action_title} — {next_action_detail} +- Next best action: {next_action_title} - {next_action_detail} Generate exactly 2 concise, personalized, and action-oriented sentences. Explain only the deterministic values above. Do not calculate new amounts. Do not invent amounts, dates, probabilities, contacts, merchant names, guarantees, or extra actions. No emojis. No preamble.""" - text = generate_text(prompt, max_tokens=150, temperature=0.25) + text = validate_grounded_advice( + generate_text(prompt, max_tokens=150, temperature=0.2), + allowed_rupee_values=_rounded_rupees( + safe_daily, + projected_daily, + ask_amount, + commitments_total, + remaining, + food_pace, + food_cap, + ), + allowed_percent_values=[shortfall_percent], + allowed_time_values=[days_left, broke_days, expected_days, stress_days, days_before_cycle_end], + max_chars=420, + ) if text: - return {"summary": text, "source": "bedrock"} + return {"summary": text, "source": "bedrock", **ai_response_metadata(source="bedrock", facts_used=facts_used)} + except GroundingError as exc: + fallback_reason = "ungrounded_response" + logger.warning("Bedrock runway-intel was ungrounded; using local fallback: %s", exc) except Exception as exc: + fallback_reason = "bedrock_unavailable" logger.warning("Bedrock runway-intel failed: %s", exc) - return {"summary": fallback_summary, "source": "local_fallback"} + return { + "summary": fallback_summary, + "source": "local_fallback", + **ai_response_metadata(source="local_fallback", facts_used=facts_used, fallback_reason=fallback_reason), + } diff --git a/backend/app/api/travel.py b/backend/app/api/travel.py index fd592fb..4f2cea5 100644 --- a/backend/app/api/travel.py +++ b/backend/app/api/travel.py @@ -11,6 +11,11 @@ from app.core.database import get_db from app.core.security import get_current_user from app.core.config import settings +from app.services.ai_guardrails import ( + GroundingError, + ai_response_metadata, + validate_grounded_advice, +) from app.services.bedrock import generate_json from app.services.travel_geo import ( build_geo_cache_key, @@ -1559,12 +1564,95 @@ def _coerce_ai_tactics(value: Any, fallback: list[str]) -> list[str]: "pairing token", ) +TRAVEL_COACH_FORBIDDEN_TERMS = ( + "live traffic", + "real-time traffic", + "real time traffic", + "ola", + "uber", + "rapido", + "cctv", + "police verified", + "guaranteed pickup", + "guaranteed drop", +) + def _is_irrelevant_coach_output(*values: Any) -> bool: combined = " ".join(str(value or "") for value in values).lower() return any(term in combined for term in COACH_IRRELEVANT_TERMS) +def _travel_coach_facts_used( + *, + route_name: str, + mode: str, + min_fare: float, + max_fare: float, + median_fare: float, + fare_anchor: float, + fare_anchor_label: str, + report_count: int, + travel_time_context: str, + app_quote: Optional[float], +) -> list[str]: + facts = [ + f"route={route_name}", + f"mode={mode}", + f"fare_range_rs={round(float(min_fare))}-{round(float(max_fare))}", + f"median_fare_rs={round(float(median_fare))}", + f"fare_anchor_rs={round(float(fare_anchor))}", + f"fare_anchor_label={fare_anchor_label}", + f"report_count={report_count}", + f"travel_time={travel_time_context}", + ] + if app_quote is not None: + facts.append(f"app_quote_rs={round(float(app_quote))}") + return facts + + +def _travel_coach_fallback_response( + fallback_response: dict[str, Any], + *, + facts_used: list[str], + fallback_reason: str, + bedrock_error: Optional[str] = None, +) -> dict[str, Any]: + response = { + **fallback_response, + "source": "local_fallback", + **ai_response_metadata( + source="local_fallback", + facts_used=facts_used, + fallback_reason=fallback_reason, + ), + } + if bedrock_error: + response["bedrock_error"] = bedrock_error + return response + + +def _travel_safety_advice(route: Optional[dict[str, Any]], time_context: str) -> str: + if not route: + return "Use a busy pickup point, confirm the vehicle, and share your trip details if you feel rushed." + + night_note = str(route.get("safety_score_night") or "").strip() + day_note = str(route.get("safety_score_day") or "").strip() + + if time_context == "late_night": + if len(night_note.split()) >= 4: + return night_note + return "Late night: prefer a pre-booked ride, avoid unknown shared autos, and share your trip details before leaving." + + if time_context == "evening" and len(night_note.split()) >= 4: + return night_note + + if len(day_note.split()) >= 4: + return day_note + + return "Use a busy pickup point, confirm the vehicle, and share your trip details if you feel rushed." + + def _normalize_ai_coach_response( result: dict[str, Any], fallback_response: dict[str, Any], @@ -1574,27 +1662,78 @@ def _normalize_ai_coach_response( fare_anchor_source: str, fare_anchor_label: str, report_count: int, + min_fare: float, + max_fare: float, + median_fare: float, + route_name: str, + mode: str, + travel_time_context: str, + app_quote: Optional[float], + facts_used: list[str], ) -> dict[str, Any]: script = _coerce_ai_text(result.get("script"), fallback_response["script"]) tactics = _coerce_ai_tactics(result.get("tactics"), fallback_response["tactics"]) safety = _coerce_ai_text(result.get("safety"), fallback_response["safety"]) if _is_irrelevant_coach_output(script, " ".join(tactics), safety): - return { - **fallback_response, - "source": "route_script", - "surge_factor": surge_factor, - "community_median": fare_anchor if fare_anchor_source == "student_reports" else None, - "fare_anchor": fare_anchor, - "fare_anchor_source": fare_anchor_source, - "fare_anchor_label": fare_anchor_label, - "report_count": report_count, - } + return _travel_coach_fallback_response( + fallback_response, + facts_used=facts_used, + fallback_reason="irrelevant_output", + bedrock_error="irrelevant_output", + ) + + allowed_rupee_values = [min_fare, max_fare, median_fare, fare_anchor] + if app_quote is not None: + allowed_rupee_values.append(app_quote) + allowed_entities = [route_name, mode, fare_anchor_label, _travel_time_label(travel_time_context)] + + try: + validated_script = validate_grounded_advice( + script, + allowed_rupee_values=allowed_rupee_values, + allowed_entities=allowed_entities, + forbidden_terms=TRAVEL_COACH_FORBIDDEN_TERMS, + max_chars=320, + max_sentences=3, + ) + + if len(tactics) < 3: + raise GroundingError("insufficient tactics") + + validated_tactics = [ + validate_grounded_advice( + tactic, + allowed_rupee_values=allowed_rupee_values, + allowed_entities=allowed_entities, + forbidden_terms=TRAVEL_COACH_FORBIDDEN_TERMS, + max_chars=200, + max_sentences=2, + ) + for tactic in tactics[:3] + ] + + validated_safety = validate_grounded_advice( + safety, + allowed_rupee_values=allowed_rupee_values, + allowed_entities=allowed_entities, + forbidden_terms=TRAVEL_COACH_FORBIDDEN_TERMS, + max_chars=180, + max_sentences=2, + ) + except GroundingError as exc: + logger.warning("Travel AI coach response was ungrounded; using local fallback: %s", exc) + return _travel_coach_fallback_response( + fallback_response, + facts_used=facts_used, + fallback_reason="ungrounded_response", + bedrock_error="ungrounded_response", + ) return { - "script": script, - "tactics": tactics, - "safety": safety, + "script": validated_script, + "tactics": validated_tactics, + "safety": validated_safety, "source": "bedrock", "surge_factor": surge_factor, "community_median": fare_anchor if fare_anchor_source == "student_reports" else None, @@ -1602,6 +1741,7 @@ def _normalize_ai_coach_response( "fare_anchor_source": fare_anchor_source, "fare_anchor_label": fare_anchor_label, "report_count": report_count, + **ai_response_metadata(source="bedrock", facts_used=facts_used), } @@ -1824,18 +1964,33 @@ async def get_ai_negotiation_coach(req: AiCoachReq, user_id: str = Depends(get_c f"Walk 100 meters away from main exit gates to hire passing running autos rather than stationary ones.", f"Refer to standard rates: Bhaiya, regular campus rate is between Rs {min_fare}-Rs {max_fare}." ], - "safety": route.get("safety_score_night", "Avoid shared/unknown routes late at night; prefer pre-booked rides.") if route else "Always prefer pre-booked rides late at night.", + "safety": _travel_safety_advice(route, normalized_time_context), "surge_factor": surge_factor, "community_median": community_median, "fare_anchor": fare_anchor, "fare_anchor_source": fare_anchor_source, "fare_anchor_label": fare_anchor_label, "report_count": report_count, - "source": "local_fallback" } + facts_used = _travel_coach_facts_used( + route_name=route_name, + mode=req.mode, + min_fare=min_fare, + max_fare=max_fare, + median_fare=median_fare, + fare_anchor=fare_anchor, + fare_anchor_label=fare_anchor_label, + report_count=report_count, + travel_time_context=normalized_time_context, + app_quote=req.app_quote, + ) if not settings.BEDROCK_ENABLED: - return fallback_response + return _travel_coach_fallback_response( + fallback_response, + facts_used=facts_used, + fallback_reason="bedrock_disabled", + ) try: prompt = build_travel_ai_prompt( @@ -1865,11 +2020,24 @@ async def get_ai_negotiation_coach(req: AiCoachReq, user_id: str = Depends(get_c fare_anchor_source=fare_anchor_source, fare_anchor_label=fare_anchor_label, report_count=report_count, + min_fare=min_fare, + max_fare=max_fare, + median_fare=median_fare, + route_name=route_name, + mode=req.mode, + travel_time_context=normalized_time_context, + app_quote=req.app_quote, + facts_used=facts_used, ) except Exception as exc: logger.warning("Bedrock AI coach failed: %s", exc) - return {**fallback_response, "bedrock_error": str(exc)} + return _travel_coach_fallback_response( + fallback_response, + facts_used=facts_used, + fallback_reason="bedrock_unavailable", + bedrock_error=str(exc), + ) diff --git a/backend/app/services/ai_guardrails.py b/backend/app/services/ai_guardrails.py new file mode 100644 index 0000000..e200666 --- /dev/null +++ b/backend/app/services/ai_guardrails.py @@ -0,0 +1,207 @@ +import re +from typing import Any, Iterable + + +AI_ADVICE_LABEL = "Grounded AI advice" +LOCAL_ADVICE_LABEL = "PocketBuddy rules" +AI_ADVICE_DISCLAIMER = ( + "Advice only. PocketBuddy's backend calculates balances, runway, prices, and limits; " + "AI only explains those facts." +) + +MEDICAL_OVERCLAIM_TERMS = ( + "diagnose", + "diagnosis", + "medical advice", + "treatment", + "illness", + "disease", + "depression", + "anxiety disorder", + "burnout risk", + "health risk", + "sleep disorder", +) + +UNSUPPORTED_CLAIM_TERMS = ( + "guaranteed", + "guarantee", + "definitely", + "live price", + "real-time price", + "live fare", + "real-time fare", + "bank verified", + "doctor", +) + +EXTERNAL_FOOD_APP_TERMS = ( + "zomato", + "swiggy", + "zepto", + "blinkit", + "instamart", + "bigbasket", + "uber eats", +) + +RUPEE_RE = re.compile(r"(?:rs\.?|inr|\u20b9)\s*([0-9][0-9,]*(?:\.[0-9]+)?)", re.IGNORECASE) +PERCENT_RE = re.compile(r"\b([0-9]+(?:\.[0-9]+)?)\s*(?:%|percent)\b", re.IGNORECASE) +TIME_RE = re.compile(r"\b([0-9]+(?:\.[0-9]+)?)\s*(?:days?|hours?|hrs?|h)\b", re.IGNORECASE) +PLAIN_NUMBER_RE = re.compile(r"(? str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" `\"'\n\t") + if not cleaned: + raise GroundingError("empty advice") + + sentences = re.split(r"(?<=[.!?])\s+", cleaned) + if len(sentences) > max_sentences: + cleaned = " ".join(sentences[:max_sentences]).strip() + + if len(cleaned) > max_chars: + raise GroundingError("advice too long") + + return cleaned + + +def ai_response_metadata( + *, + source: str, + facts_used: Iterable[str] = (), + fallback_reason: str | None = None, +) -> dict[str, Any]: + fallback_used = source != "bedrock" + return { + "advice_label": AI_ADVICE_LABEL if source == "bedrock" else LOCAL_ADVICE_LABEL, + "advice_disclaimer": AI_ADVICE_DISCLAIMER, + "grounding": { + "status": "grounded" if source == "bedrock" else "deterministic_fallback", + "numbers_from_backend": True, + "fallback_used": fallback_used, + "fallback_reason": fallback_reason, + "facts_used": [str(fact) for fact in facts_used if fact][:8], + }, + } + + +def validate_grounded_advice( + text: Any, + *, + allowed_rupee_values: Iterable[float | int] = (), + allowed_percent_values: Iterable[float | int] = (), + allowed_time_values: Iterable[float | int] = (), + allowed_plain_values: Iterable[float | int] | None = None, + allowed_entities: Iterable[str] = (), + require_entity: bool = False, + forbidden_terms: Iterable[str] = (), + max_chars: int = 420, + max_sentences: int = 2, +) -> str: + cleaned = normalize_advice_text(text, max_chars=max_chars, max_sentences=max_sentences) + lower = cleaned.lower() + + blocked_terms = tuple(MEDICAL_OVERCLAIM_TERMS) + tuple(UNSUPPORTED_CLAIM_TERMS) + tuple(forbidden_terms) + blocked = [term for term in blocked_terms if term and term.lower() in lower] + if blocked: + raise GroundingError(f"blocked unsupported terms: {', '.join(sorted(set(blocked)))}") + + _assert_numbers_grounded( + RUPEE_RE.findall(cleaned), + allowed_rupee_values, + kind="rupee", + tolerance_floor=1.0, + ) + _assert_numbers_grounded( + PERCENT_RE.findall(cleaned), + allowed_percent_values, + kind="percent", + tolerance_floor=1.0, + ) + _assert_numbers_grounded( + [match.group(1) for match in TIME_RE.finditer(cleaned)], + allowed_time_values, + kind="time", + tolerance_floor=1.0, + ) + if allowed_plain_values is not None: + _assert_numbers_grounded( + _plain_number_values(cleaned), + allowed_plain_values, + kind="plain", + tolerance_floor=0.0, + relative_tolerance=0.0, + ) + + entity_values = [entity.strip().lower() for entity in allowed_entities if str(entity).strip()] + if require_entity and entity_values and not any(entity in lower for entity in entity_values): + raise GroundingError("advice did not reference a trusted entity") + + return cleaned + + +def _assert_numbers_grounded( + values: Iterable[str], + allowed_values: Iterable[float | int], + *, + kind: str, + tolerance_floor: float, + relative_tolerance: float = 0.015, +) -> None: + allowed = [float(value) for value in allowed_values if _is_finite_number(value)] + unsupported: list[str] = [] + for raw_value in values: + value = _parse_number(raw_value) + if value is None: + continue + if not allowed or not any( + _close_number(value, candidate, tolerance_floor, relative_tolerance) + for candidate in allowed + ): + unsupported.append(raw_value) + + if unsupported: + raise GroundingError(f"unsupported {kind} numbers: {', '.join(unsupported)}") + + +def _parse_number(value: str) -> float | None: + try: + return float(str(value).replace(",", "")) + except (TypeError, ValueError): + return None + + +def _is_finite_number(value: Any) -> bool: + try: + number = float(value) + except (TypeError, ValueError): + return False + return number == number and number not in (float("inf"), float("-inf")) + + +def _close_number(value: float, candidate: float, tolerance_floor: float, relative_tolerance: float) -> bool: + tolerance = max(tolerance_floor, abs(candidate) * relative_tolerance) + return abs(value - candidate) <= tolerance + + +def _plain_number_values(text: str) -> list[str]: + ignored_spans = [ + match.span() + for pattern in (RUPEE_RE, PERCENT_RE, TIME_RE) + for match in pattern.finditer(text) + ] + values: list[str] = [] + for match in PLAIN_NUMBER_RE.finditer(text): + if any(_spans_overlap(match.span(), span) for span in ignored_spans): + continue + values.append(match.group(1)) + return values + + +def _spans_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool: + return a[0] < b[1] and b[0] < a[1] diff --git a/backend/tests/test_ai_guardrails.py b/backend/tests/test_ai_guardrails.py new file mode 100644 index 0000000..7a6beb4 --- /dev/null +++ b/backend/tests/test_ai_guardrails.py @@ -0,0 +1,76 @@ +import unittest + +from app.services.ai_guardrails import ( + GroundingError, + ai_response_metadata, + validate_grounded_advice, +) + + +class AiGuardrailTests(unittest.TestCase): + def test_allows_backend_supplied_numbers_and_entities(self): + text = validate_grounded_advice( + "Try Egg Paratha at BH-2 for Rs 45. It fits the 8 hours food gap.", + allowed_rupee_values=[45], + allowed_time_values=[8], + allowed_entities=["Egg Paratha", "BH-2"], + require_entity=True, + ) + + self.assertIn("Egg Paratha", text) + + def test_rejects_invented_rupee_amounts(self): + with self.assertRaises(GroundingError): + validate_grounded_advice( + "Keep spend under Rs 250 today.", + allowed_rupee_values=[120], + ) + + def test_rejects_external_food_app_drift(self): + with self.assertRaises(GroundingError): + validate_grounded_advice( + "Order from Zepto for Rs 45.", + allowed_rupee_values=[45], + forbidden_terms=["zepto"], + ) + + def test_allows_grounded_plain_numbers_when_enabled(self): + text = validate_grounded_advice( + "Rs 600 is scheduled across 3 commitments.", + allowed_rupee_values=[600], + allowed_plain_values=[3], + ) + + self.assertIn("3 commitments", text) + + def test_rejects_ungrounded_plain_numbers_when_enabled(self): + with self.assertRaises(GroundingError): + validate_grounded_advice( + "Rs 600 is scheduled across 4 commitments.", + allowed_rupee_values=[600], + allowed_plain_values=[3], + ) + + def test_rejects_medical_overclaims(self): + with self.assertRaises(GroundingError): + validate_grounded_advice( + "Your burnout risk is high, so eat soon.", + allowed_time_values=[], + ) + + def test_metadata_labels_ai_vs_fallback(self): + ai_meta = ai_response_metadata(source="bedrock", facts_used=["safe_daily_spend_rs=120"]) + fallback_meta = ai_response_metadata( + source="local_fallback", + facts_used=["safe_daily_spend_rs=120"], + fallback_reason="bedrock_unavailable", + ) + + self.assertEqual(ai_meta["advice_label"], "Grounded AI advice") + self.assertEqual(ai_meta["grounding"]["status"], "grounded") + self.assertTrue(fallback_meta["grounding"]["fallback_used"]) + self.assertEqual(fallback_meta["grounding"]["fallback_reason"], "bedrock_unavailable") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_food_rag_recommendation.py b/backend/tests/test_food_rag_recommendation.py index c5f8d7e..347614a 100644 --- a/backend/tests/test_food_rag_recommendation.py +++ b/backend/tests/test_food_rag_recommendation.py @@ -4,7 +4,7 @@ os.environ.setdefault("JWT_SECRET", "test-secret") os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017/pocketbuddy_test") -from app.api.rag import RagReq, build_local_recommendation # noqa: E402 +from app.api.rag import RagReq, _build_local_campus_insight, build_local_recommendation # noqa: E402 class FoodRagRecommendationTests(unittest.TestCase): @@ -39,6 +39,30 @@ def test_local_food_recommendation_ignores_review_candidates(self): self.assertEqual(result["item"]["item_name"], "Egg Paratha") self.assertNotIn("OCR Meal", result["recommendation"]) + def test_local_campus_intel_returns_structured_food_nudge(self): + result = _build_local_campus_insight( + spend_7=420, + remaining=1800, + days_left=12, + safe_daily=150, + last_food_hours=11, + upcoming_commitments=0, + upcoming_commitment_count=0, + food_option={ + "venue": "BH-2 Night Canteen", + "item": "Egg Paratha", + "price_rs": 45, + "trust": "Trusted", + "why": "Open late", + }, + ) + + self.assertEqual(result["focus"], "routine") + self.assertEqual(result["headline"], "Routine check due") + self.assertNotIn("Egg Paratha", result["next_action"]) + self.assertIn("11 hours", result["why"]) + self.assertEqual([signal["label"] for signal in result["signals"]], ["Runway", "Spend pace", "Commitments", "Routine"]) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_travel_guard_trust.py b/backend/tests/test_travel_guard_trust.py index b7d2553..3e1545e 100644 --- a/backend/tests/test_travel_guard_trust.py +++ b/backend/tests/test_travel_guard_trust.py @@ -6,6 +6,8 @@ os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017/pocketbuddy_test") from app.api.travel import ( # noqa: E402 + _normalize_ai_coach_response, + _travel_safety_advice, build_fare_explanation, build_ride_pool_safety_context, build_travel_report_candidate, @@ -148,6 +150,110 @@ def test_nova_prompt_forbids_invented_fares_and_live_app_claims(self): self.assertIn("Selected travel timing", prompt) self.assertIn("Output ONLY valid JSON", prompt) + def test_travel_ai_coach_falls_back_when_bedrock_invents_fare_numbers(self): + fallback_response = { + "script": "Bhaiya, ABV-IIITM Gate 1 chalo na. Regular student rate Rs 165 hai.", + "tactics": [ + "Compare the quote with the Rs 165 anchor before agreeing.", + "Walk 100 meters away from the station stand before negotiating.", + "Stay within the Rs 140 to Rs 180 campus range.", + ], + "safety": "Use a busy pickup point, confirm the vehicle, and share your trip details if you feel rushed.", + "surge_factor": 1.0, + "community_median": 165, + "fare_anchor": 165, + "fare_anchor_source": "student_reports", + "fare_anchor_label": "5 distinct student reports", + "report_count": 5, + } + + response = _normalize_ai_coach_response( + { + "script": "Bhaiya, Rs 320 final kar lo.", + "tactics": [ + "Use the live Uber fare as the real benchmark.", + "Tell the driver student reports say Rs 320 is normal.", + "Accept anything under Rs 300 at night.", + ], + "safety": "The CCTV coverage keeps this route guaranteed safe.", + }, + fallback_response, + surge_factor=1.0, + fare_anchor=165, + fare_anchor_source="student_reports", + fare_anchor_label="5 distinct student reports", + report_count=5, + min_fare=140, + max_fare=180, + median_fare=160, + route_name="Gwalior Railway Station to ABV-IIITM", + mode="Auto", + travel_time_context="evening", + app_quote=None, + facts_used=["route=Gwalior Railway Station to ABV-IIITM", "fare_anchor_rs=165"], + ) + + self.assertEqual(response["source"], "local_fallback") + self.assertEqual(response["script"], fallback_response["script"]) + self.assertEqual(response["bedrock_error"], "ungrounded_response") + self.assertTrue(response["grounding"]["fallback_used"]) + + def test_travel_ai_coach_keeps_grounded_backend_numbers(self): + fallback_response = { + "script": "fallback script", + "tactics": ["fallback one", "fallback two", "fallback three"], + "safety": "fallback safety", + "surge_factor": 1.18, + "community_median": 165, + "fare_anchor": 165, + "fare_anchor_source": "student_reports", + "fare_anchor_label": "5 distinct student reports", + "report_count": 5, + } + + response = _normalize_ai_coach_response( + { + "script": "Bhaiya, regular student rate Rs 165 hai. Rs 165 final?", + "tactics": [ + "Compare any app quote with Rs 165 before agreeing.", + "If the quote stays above Rs 180, step away from the stand and ask the next driver.", + "Use the Rs 140 to Rs 180 campus range as the counter-anchor.", + ], + "safety": "Use a busy pickup point and share your trip details before leaving.", + }, + fallback_response, + surge_factor=1.18, + fare_anchor=165, + fare_anchor_source="student_reports", + fare_anchor_label="5 distinct student reports", + report_count=5, + min_fare=140, + max_fare=180, + median_fare=160, + route_name="Gwalior Railway Station to ABV-IIITM", + mode="Auto", + travel_time_context="evening", + app_quote=195, + facts_used=["route=Gwalior Railway Station to ABV-IIITM", "fare_anchor_rs=165"], + ) + + self.assertEqual(response["source"], "bedrock") + self.assertEqual(len(response["tactics"]), 3) + self.assertIn("Rs 165", response["script"]) + self.assertEqual(response["grounding"]["status"], "grounded") + + def test_travel_safety_advice_stays_actionable_when_day_score_is_only_a_label(self): + advice = _travel_safety_advice( + { + "safety_score_day": "High Safety", + "safety_score_night": "Avoid shared routes after 9:00 PM. Prefer pre-booked cabs.", + }, + "afternoon", + ) + + self.assertIn("busy pickup point", advice.lower()) + self.assertNotEqual(advice, "High Safety") + def test_travel_time_context_normalizes_user_selected_periods(self): self.assertEqual(_normalize_travel_time_context("Morning"), "morning") self.assertEqual(_normalize_travel_time_context("evening_rush"), "evening") diff --git a/frontend/src/routes/_authenticated/dashboard.lazy.tsx b/frontend/src/routes/_authenticated/dashboard.lazy.tsx index 1949d8b..1beba5f 100644 --- a/frontend/src/routes/_authenticated/dashboard.lazy.tsx +++ b/frontend/src/routes/_authenticated/dashboard.lazy.tsx @@ -6,7 +6,7 @@ import { AppShell, MobileMenuButton } from "@/components/AppShell"; import { PlatformIcon } from "@/components/PlatformIcon"; import { Plus, ChevronRight, AlertTriangle, Users, Utensils, ShoppingBag, - Bus, Receipt, MoreHorizontal, Wallet, Timer, MapPin, Compass, TrendingDown, Calendar, + Bus, Receipt, MoreHorizontal, Wallet, Timer, MapPin, Compass, TrendingDown, Calendar, ShieldCheck, ChevronDown, ChevronUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -2491,36 +2491,68 @@ function Dashboard() { {/* ── Interactive Student Allocation Planner ─────────────────── */} {/* ── AI Campus Intelligence (Bedrock) ──────────────────── */} -
-
-
- AI +
+
+
+
+ +
+
+

Campus Intelligence

+

Cycle balance, spend pace, commitments, and routine.

+
-

Campus Intelligence

{campusIntel?.source === "bedrock" && ( - Bedrock + + Bedrock + )}
- {campusIntel?.summary ? ( -

{campusIntel.summary}

- ) : ( -
-
-
-
-
- )} - {campusIntel && ( -
-
-

This Week

-

{rupees((campusIntel.spend_7d ?? 0) * 100)}

+ {campusIntel?.headline ? ( +
+
+

{campusIntel.headline}

+

{campusIntel.next_action ?? campusIntel.summary}

-
-

Meal Signal

-

8 ? "text-warning" : "text-success"}`}> - {campusIntel.last_food_hours > 0 ? `${Math.round(campusIntel.last_food_hours)}h ago` : "—"} -

+ {campusIntel.why && ( +

{campusIntel.why}

+ )} + +
+ {(() => { + const signals = campusIntel.signals ?? [ + { label: "Runway", value: rupees((campusIntel.safe_daily ?? 0) * 100) + "/day", detail: "Safe spend", tone: "steady" }, + { label: "Spend pace", value: rupees((campusIntel.spend_7d ?? 0) * 100), detail: "Last 7 days", tone: "steady" }, + { label: "Commitments", value: rupees((campusIntel.upcoming_commitments ?? 0) * 100), detail: "Next 7 days", tone: "steady" }, + ]; + const visibleSignals = signals + .filter((signal: any, index: number) => index < 2 || signal.tone === "watch") + .slice(0, 3); + + return visibleSignals.map((signal: any) => ( +
+
+
+ +

{signal.label}

+
+

{signal.detail}

+
+

{signal.value}

+
+ )); + })()} +
+
+ ) : ( +
+ + + +
+ + +
)} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 6da1f63..18d2257 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1103,7 +1103,7 @@ function LandingPage() { const features = [ { icon: Smartphone, title: "Privacy-first Instant UPI Sync", description: "The optional Android connector parses supported payment alerts on-device and sends only transaction facts ── never raw notification text.", accent: "#8C7853", delay: 0 }, { icon: Map, title: "Crowdsourced Merchant Mapping", description: "Raw strings like SHREE_BALAJI_ENT resolve into 'Hostel 1 Night Canteen' via 1-tap crowd classification, shared globally across campus.", accent: "#C27D56", delay: 100 }, - { icon: Zap, title: "Geofenced AI Guard", description: "Amazon Bedrock analyzes your runway against a live campus food database to surface hyper-local, cost-effective meal alternatives.", accent: "#D9A05B", delay: 200 }, + { icon: Zap, title: "Campus Intelligence", description: "Turns runway, commitments, routine signals, and trusted campus prices into one practical next step.", accent: "#D9A05B", delay: 200 }, { icon: ShoppingCart, title: "Wing Cart Pooler", description: "Open a Blinkit/Zepto pool, share it on WhatsApp, let roommates add items ── delivery fees split automatically. No install needed.", accent: "#F7EC13", delay: 0 }, { icon: CalendarCheck, title: "Exam-Week Check-In", description: "If no food transaction is detected for 16+ hours during exam week, PocketBuddy pings you and suggests the nearest open campus canteen.", accent: "#5E17EB", delay: 100 }, { icon: Bell, title: "Subscription Collision Guard", description: "Auto-detects recurring Spotify, YouTube & gaming debits, then flags exact days when they tighten your food runway.", accent: "#FC8019", delay: 200 }, @@ -1306,9 +1306,9 @@ function LandingPage() {
Your financial runway, live. -

One glance tells you everything ── days until broke, safe daily spend limit, AI-suggested campus meals, active Wing pools, and your routine signal index. All computed from payments, check-ins, and campus context.

+

One glance tells you everything: days until broke, safe daily spend limit, grounded campus meal advice, active Wing pools, and routine signals. All computed from PocketBuddy facts, with AI used only to explain next steps.

- {["Live runway countdown with exact HH:MM:SS timer", "Routine signal score from concrete budget signals", "Hyper-local Bedrock meal suggestions", "Crowdsourced merchant recognition", "Subscription collision calendar"].map((item) => ( + {["Live runway countdown with exact HH:MM:SS timer", "Routine signal nudges from real behavioral signals", "Campus-aware next steps from trusted data", "Crowdsourced merchant recognition", "Subscription collision calendar"].map((item) => (
{item} @@ -1424,7 +1424,7 @@ function LandingPage() { - +