From d2de91bbc32965090b2d7adeca8a4ed52f502413 Mon Sep 17 00:00:00 2001 From: Kanika Date: Thu, 9 Jul 2026 12:05:18 +0530 Subject: [PATCH 1/2] Harden Bedrock campus intelligence --- backend/app/api/rag.py | 505 +++++++++++++++--- backend/app/services/ai_guardrails.py | 175 ++++++ backend/tests/test_ai_guardrails.py | 59 ++ backend/tests/test_food_rag_recommendation.py | 26 +- .../routes/_authenticated/dashboard.lazy.tsx | 85 ++- .../src/routes/_authenticated/runway.lazy.tsx | 23 +- frontend/src/routes/index.tsx | 22 +- 7 files changed, 780 insertions(+), 115 deletions(-) create mode 100644 backend/app/services/ai_guardrails.py create mode 100644 backend/tests/test_ai_guardrails.py diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py index 0e66c18..d210fae 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 router = APIRouter() @@ -24,6 +30,160 @@ 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): + return value.replace(tzinfo=None) + if isinstance(value, str): + try: + return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + 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() @@ -35,35 +195,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,65 +334,192 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): profile = await db.profiles.find_one({"_id": user_id}) # Basic spending stats - since_7 = datetime.datetime.utcnow() - datetime.timedelta(days=7) - cursor = db.transactions.find({"user_id": user_id, "created_at": {"$gte": since_7}}) + now = datetime.datetime.utcnow() + since_7 = now - datetime.timedelta(days=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 + spend_7 = sum(_doc_amount_paise(t) for t in txns if _is_debit_transaction(t) and (_doc_datetime(t.get("created_at")) or now) >= since_7) / 100 + cycle_spend = sum(_doc_amount_paise(t) for t in txns if _is_debit_transaction(t) and cycle_start <= (_doc_datetime(t.get("created_at")) or now) < cycle_end) / 100 food_txns = [t for t in txns if t.get("category") == "food"] last_food_hours = 0 if food_txns: last_food = max(food_txns, key=lambda t: t.get("created_at", datetime.datetime.min)) - last_food_hours = (datetime.datetime.utcnow() - last_food["created_at"]).total_seconds() / 3600 + last_food_at = _doc_datetime(last_food.get("created_at")) or now + last_food_hours = (now - last_food_at).total_seconds() / 3600 - 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_transaction_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, an AI financial wellness guard for Indian college students. Student context: -- Spent Rs {spend_7:.0f} in last 7 days -- Remaining budget: Rs {remaining:.0f} -- Last food transaction: {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, mention real numbers. No emojis. Do not cite any food option outside the trusted campus food options above.""" - - text = generate_text(prompt, max_tokens=120, temperature=0.2) - if text: - return {"summary": text, "source": "bedrock", "spend_7d": spend_7, "last_food_hours": round(last_food_hours, 1)} +- 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"}} +""" + + 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=_rounded_rupees(spend_7, remaining, safe_daily, weekly_daily_pace, upcoming_commitments, safe_budget_paise / 100) + + _trusted_food_rupees(ranked_foods[:5]), + allowed_time_values=[7, days_left, last_food_hours, upcoming_commitment_count], + allowed_entities=_trusted_food_entities(ranked_foods[:5]), + forbidden_terms=EXTERNAL_FOOD_APP_TERMS, + max_chars=80, + max_sentences=1, + ) + next_action = validate_grounded_advice( + result.get("next_action"), + allowed_rupee_values=_rounded_rupees(spend_7, remaining, safe_daily, weekly_daily_pace, upcoming_commitments, safe_budget_paise / 100) + + _trusted_food_rupees(ranked_foods[:5]), + allowed_time_values=[7, days_left, last_food_hours, upcoming_commitment_count], + allowed_entities=_trusted_food_entities(ranked_foods[:5]), + 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=_rounded_rupees(spend_7, remaining, safe_daily, weekly_daily_pace, upcoming_commitments, safe_budget_paise / 100) + + _trusted_food_rupees(ranked_foods[:5]), + allowed_time_values=[7, days_left, last_food_hours, upcoming_commitment_count], + allowed_entities=_trusted_food_entities(ranked_foods[:5]), + forbidden_terms=EXTERNAL_FOOD_APP_TERMS, + max_chars=180, + max_sentences=1, + ) + summary = validate_grounded_advice( + result.get("summary"), + allowed_rupee_values=_rounded_rupees(spend_7, remaining, safe_daily, weekly_daily_pace, upcoming_commitments, safe_budget_paise / 100) + + _trusted_food_rupees(ranked_foods[:5]), + allowed_time_values=[7, days_left, last_food_hours, upcoming_commitment_count], + allowed_entities=_trusted_food_entities(ranked_foods[:5]), + forbidden_terms=EXTERNAL_FOOD_APP_TERMS, + max_chars=360, + max_sentences=2, + ) + if summary: + return { + "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), + **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 - parts = [] - if spend_7 > 0: - parts.append(f"You've spent ₹{spend_7:.0f} in the last 7 days.") - if last_food_hours > 8: - parts.append(f"Your last food transaction was {last_food_hours:.0f} hours ago — consider eating soon.") - elif last_food_hours > 0: - parts.append(f"Last meal logged {last_food_hours:.0f} hours ago, you're on track.") - if remaining > 0: - parts.append(f"₹{remaining:.0f} remaining in your current cycle.") - summary = " ".join(parts) if parts else "Start logging transactions to activate campus intelligence." - return {"summary": summary, "source": "local_fallback", "spend_7d": spend_7, "last_food_hours": round(last_food_hours, 1)} + return { + **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), + **ai_response_metadata(source="local_fallback", facts_used=facts_used, fallback_reason=fallback_reason), + } @router.get("/runway-intel") @@ -253,6 +597,19 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)): next_action_title = str(next_action.get("title") or "Keep runway stable") next_action_detail = str(next_action.get("detail") or "Keep discretionary spend inside the safe daily limit.") decision_summary = str(decision_engine.get("summary") or "") + 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"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 == "shortfall": @@ -280,7 +637,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}% - Days until broke: {broke_days} (out of {days_left} days left) - Ask home amount needed: Rs {ask_amount} @@ -293,10 +650,32 @@ async def get_runway_intel(user_id: str = Depends(get_current_user)): Generate exactly 2 concise, personalized, and action-oriented sentences. Be direct, reference the specific numbers, and suggest concrete actions that match their meal routine. Do not invent phone numbers, contacts, merchant names, or guarantees. 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, 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/services/ai_guardrails.py b/backend/app/services/ai_guardrails.py new file mode 100644 index 0000000..b600688 --- /dev/null +++ b/backend/app/services/ai_guardrails.py @@ -0,0 +1,175 @@ +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) + + +class GroundingError(ValueError): + """Raised when AI advice uses unsupported facts or overclaims.""" + + +def normalize_advice_text(text: Any, *, max_chars: int = 420, max_sentences: int = 2) -> 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_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, + ) + + 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, +) -> 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) 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) -> bool: + tolerance = max(tolerance_floor, abs(candidate) * 0.015) + return abs(value - candidate) <= tolerance diff --git a/backend/tests/test_ai_guardrails.py b/backend/tests/test_ai_guardrails.py new file mode 100644 index 0000000..a5e9752 --- /dev/null +++ b/backend/tests/test_ai_guardrails.py @@ -0,0 +1,59 @@ +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_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/frontend/src/routes/_authenticated/dashboard.lazy.tsx b/frontend/src/routes/_authenticated/dashboard.lazy.tsx index 94d1454..6d24fb3 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, MessageSquare, Phone, Mail, MapPin, ExternalLink, Compass, TrendingDown, Calendar, + Bus, Receipt, MoreHorizontal, Wallet, Timer, MessageSquare, Phone, Mail, MapPin, ExternalLink, Compass, TrendingDown, Calendar, ShieldCheck, ChevronDown, ChevronUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -2384,37 +2384,64 @@ function Dashboard() { {/* ── AI Campus Intelligence (Bedrock) ──────────────────── */} -
-
-
-
- AI +
+
+
+
+ +
+
+

Campus Intelligence

+

Cycle balance, spend pace, commitments, and routine.

+
-

Campus Intelligence

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

{campusIntel.summary}

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

This Week

-

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

+ {campusIntel?.headline ? ( +
+
+

{campusIntel.headline}

+

{campusIntel.next_action ?? campusIntel.summary}

-
-

Last Meal

-

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" }, + { label: "Routine", value: campusIntel.last_food_hours > 0 ? `${Math.round(campusIntel.last_food_hours)}h ago` : "No signal", detail: "Latest signal", 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/_authenticated/runway.lazy.tsx b/frontend/src/routes/_authenticated/runway.lazy.tsx index a1b5c91..f75f09f 100644 --- a/frontend/src/routes/_authenticated/runway.lazy.tsx +++ b/frontend/src/routes/_authenticated/runway.lazy.tsx @@ -427,17 +427,18 @@ Generated via PocketBuddy Runway.`;
{/* ── Runway Advisor Narration ── */} -
-
- - - - -
-
- -

Runway Advisor

- Amazon Bedrock Nova +
+
+
+
+
+ +
+
+

Runway Advisor

+

Next step for the current cycle.

+
+
{intelLoading ? (
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 9548b24..a49547d 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -783,9 +783,9 @@ function DashboardMockup() {
tapCard("ai")}>
- AI GUARD · BEDROCK + CAMPUS INTEL
-
BH-2 Night Canteen: Egg Paratha ₹45 · Open till 2AM
+
BH-2 Night Canteen: Egg Paratha Rs 45 from trusted campus data
); @@ -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'll slice your food runway to dangerous levels.", accent: "#FC8019", delay: 200 }, @@ -1114,7 +1114,7 @@ function LandingPage() { { q: "What if I don't have the Android companion app?", a: "You can still use PocketBuddy in full manual mode ── log transactions in one tap, get AI food suggestions, join Wing Cart Pools, and track subscriptions. The companion just makes it passive and offline-syncing." }, { q: "How does the crowdsourced merchant mapping work?", a: "When a new merchant string appears (e.g. SHREE_BALAJI_ENT), you get a 1-tap prompt to classify it. Once classified, it's immediately resolved for every student on your campus ── your 10 seconds of effort saves hundreds of others the same friction." }, { q: "Is this only for one campus?", a: "No. PocketBuddy works for any residential campus. The campus food database is seeded per college and grows through crowdsourcing, so each university can start with its own menus and routes." }, - { q: "How is the Burnout Risk Score calculated?", a: "It's derived from four real signals: food gap hours (time since last food transaction), exam period overlap, spending velocity spike vs. prior week, and late-night transaction patterns. No subjective surveys ── it's entirely data-driven." }, + { q: "How is the routine signal calculated?", a: "It uses backend facts such as food gap hours, exam period overlap, spending velocity changes, and late-night transaction timing. It is a practical nudge, not a medical or mental-health assessment." }, ]; const comparisons = [ @@ -1122,7 +1122,7 @@ function LandingPage() { { feature: "UPI push notification ingestion", us: true, fi: false, mint: false, splitwise: false }, { feature: "Campus-specific food intelligence", us: true, fi: false, mint: false, splitwise: false }, { feature: "Crowdsourced merchant mapping", us: true, fi: false, mint: false, splitwise: false }, - { feature: "Burnout risk detection", us: true, fi: false, mint: false, splitwise: false }, + { feature: "Routine signal nudges", us: true, fi: false, mint: false, splitwise: false }, { feature: "Delivery fee split pooling", us: true, fi: false, mint: false, splitwise: true }, { feature: "Subscription collision alerts", us: true, fi: true, mint: true, splitwise: false }, { feature: "Exam-period food monitoring", us: true, fi: false, mint: false, splitwise: false }, @@ -1131,7 +1131,7 @@ function LandingPage() { const problems = [ { icon: Banknote, stat: "120", sub: "avg units wasted monthly on delivery surge fees by dorm students", color: "#FC8019" }, - { icon: Utensils, stat: "3 in 5", sub: "students skip a meal during exam week due to financial anxiety", color: "#ef4444" }, + { icon: Utensils, stat: "3 in 5", sub: "students skip a meal during exam week due to budget pressure", color: "#ef4444" }, { icon: Smartphone, stat: "94%", sub: "of students abandon manual finance apps within 2 weeks", color: "#f59e0b" }, { icon: Moon, stat: "80", sub: "spent late-night per month on impulse delivery orders", color: "#5E17EB" }, ]; @@ -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 burnout risk index. All computed passively from your UPI notifications.

+

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", "AI burnout risk score from 5 real behavioral 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} @@ -1326,7 +1326,7 @@ function LandingPage() {
{[ { value: "0", label: "MANUAL ENTRIES NEEDED" }, - { value: "16+h", label: "BURNOUT DETECTION THRESHOLD" }, + { value: "16+h", label: "MEAL GAP NUDGE WINDOW" }, { value: "75%", label: "TOKEN COST REDUCTION VIA RAG" }, { value: "∞", label: "CAMPUS MERCHANTS MAPPABLE" }, ].map(({ value, label }) => ( @@ -1423,8 +1423,8 @@ function LandingPage() { - - + +
From 0fa04d8e2719405eff6a5427101df0da4b71ba65 Mon Sep 17 00:00:00 2001 From: Kanika Date: Thu, 9 Jul 2026 12:21:05 +0530 Subject: [PATCH 2/2] Harden Bedrock grounding across campus intelligence --- backend/app/api/rag.py | 15 +- backend/app/api/travel.py | 202 +++++++++++++++++++++-- backend/app/services/ai_guardrails.py | 38 ++++- backend/tests/test_ai_guardrails.py | 17 ++ backend/tests/test_travel_guard_trust.py | 106 ++++++++++++ 5 files changed, 355 insertions(+), 23 deletions(-) diff --git a/backend/app/api/rag.py b/backend/app/api/rag.py index d210fae..7b60fec 100644 --- a/backend/app/api/rag.py +++ b/backend/app/api/rag.py @@ -90,10 +90,15 @@ def _doc_amount_paise(item: dict | None) -> int: def _doc_datetime(value) -> datetime.datetime | None: if isinstance(value, datetime.datetime): - return value.replace(tzinfo=None) + if value.tzinfo is not None: + return value.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return value if isinstance(value, str): try: - return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + 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 @@ -341,7 +346,7 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): txns = await cursor.to_list(length=500) spend_7 = sum(_doc_amount_paise(t) for t in txns if _is_debit_transaction(t) and (_doc_datetime(t.get("created_at")) or now) >= since_7) / 100 cycle_spend = sum(_doc_amount_paise(t) for t in txns if _is_debit_transaction(t) and cycle_start <= (_doc_datetime(t.get("created_at")) or now) < cycle_end) / 100 - food_txns = [t for t in txns if t.get("category") == "food"] + food_txns = [t for t in txns if t.get("category") == "food" and _is_debit_transaction(t)] last_food_hours = 0 if food_txns: last_food = max(food_txns, key=lambda t: t.get("created_at", datetime.datetime.min)) @@ -446,6 +451,7 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): allowed_rupee_values=_rounded_rupees(spend_7, remaining, safe_daily, weekly_daily_pace, upcoming_commitments, safe_budget_paise / 100) + _trusted_food_rupees(ranked_foods[:5]), allowed_time_values=[7, days_left, last_food_hours, upcoming_commitment_count], + allowed_plain_values=[upcoming_commitment_count], allowed_entities=_trusted_food_entities(ranked_foods[:5]), forbidden_terms=EXTERNAL_FOOD_APP_TERMS, max_chars=80, @@ -456,6 +462,7 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): allowed_rupee_values=_rounded_rupees(spend_7, remaining, safe_daily, weekly_daily_pace, upcoming_commitments, safe_budget_paise / 100) + _trusted_food_rupees(ranked_foods[:5]), allowed_time_values=[7, days_left, last_food_hours, upcoming_commitment_count], + allowed_plain_values=[upcoming_commitment_count], allowed_entities=_trusted_food_entities(ranked_foods[:5]), require_entity=focus == "routine" and last_food_hours > 10 and bool(ranked_foods), forbidden_terms=EXTERNAL_FOOD_APP_TERMS, @@ -467,6 +474,7 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): allowed_rupee_values=_rounded_rupees(spend_7, remaining, safe_daily, weekly_daily_pace, upcoming_commitments, safe_budget_paise / 100) + _trusted_food_rupees(ranked_foods[:5]), allowed_time_values=[7, days_left, last_food_hours, upcoming_commitment_count], + allowed_plain_values=[upcoming_commitment_count], allowed_entities=_trusted_food_entities(ranked_foods[:5]), forbidden_terms=EXTERNAL_FOOD_APP_TERMS, max_chars=180, @@ -477,6 +485,7 @@ async def get_campus_intel(user_id: str = Depends(get_current_user)): allowed_rupee_values=_rounded_rupees(spend_7, remaining, safe_daily, weekly_daily_pace, upcoming_commitments, safe_budget_paise / 100) + _trusted_food_rupees(ranked_foods[:5]), allowed_time_values=[7, days_left, last_food_hours, upcoming_commitment_count], + allowed_plain_values=[upcoming_commitment_count], allowed_entities=_trusted_food_entities(ranked_foods[:5]), forbidden_terms=EXTERNAL_FOOD_APP_TERMS, max_chars=360, 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 index b600688..e200666 100644 --- a/backend/app/services/ai_guardrails.py +++ b/backend/app/services/ai_guardrails.py @@ -48,6 +48,7 @@ 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"(? None: allowed = [float(value) for value in allowed_values if _is_finite_number(value)] unsupported: list[str] = [] @@ -148,7 +159,10 @@ def _assert_numbers_grounded( value = _parse_number(raw_value) if value is None: continue - if not allowed or not any(_close_number(value, candidate, tolerance_floor) for candidate in allowed): + if not allowed or not any( + _close_number(value, candidate, tolerance_floor, relative_tolerance) + for candidate in allowed + ): unsupported.append(raw_value) if unsupported: @@ -170,6 +184,24 @@ def _is_finite_number(value: Any) -> bool: return number == number and number not in (float("inf"), float("-inf")) -def _close_number(value: float, candidate: float, tolerance_floor: float) -> bool: - tolerance = max(tolerance_floor, abs(candidate) * 0.015) +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 index a5e9752..7a6beb4 100644 --- a/backend/tests/test_ai_guardrails.py +++ b/backend/tests/test_ai_guardrails.py @@ -34,6 +34,23 @@ def test_rejects_external_food_app_drift(self): 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( 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")