From e2cd34f9cb3202373b99df78f51068c4c24ba361 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:19:02 +0530 Subject: [PATCH 01/95] Fix chatbot active listing retrieval, nearby evidence and request limits --- supabase/functions/studenthubhelp-chat/index.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 97d93ce28..796fcedff 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -13,8 +13,8 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKey(req:Request){const f=req.headers.get("x-forwarded-for")||req.headers.get("cf-connecting-ip")||"unknown";return f.split(",")[0].trim().slice(0,100)||"unknown"} -function allowed(req:Request){const key=rateKey(req),now=Date.now(),old=rateMap.get(key);if(!old||now-old.start>=RATE_WINDOW_MS){rateMap.set(key,{start:now,count:1});return true}if(old.count>=RATE_MAX)return false;old.count++;return true} +function rateKey(req:Request){const f=req.headers.get("cf-connecting-ip")||req.headers.get("x-real-ip")||"unknown";return f.trim().slice(0,100)||"unknown"} +function pruneRateMap(map:Map,now:number){if(map.size<5000)return;for(const [key,value] of map){if(now-value.start>Math.max(RATE_WINDOW_MS,OWNER_LEAD_WINDOW_MS))map.delete(key);if(map.size<4000)break}}\nfunction allowed(req:Request){const key=rateKey(req),now=Date.now(),old=rateMap.get(key);pruneRateMap(rateMap,now);if(!old||now-old.start>=RATE_WINDOW_MS){rateMap.set(key,{start:now,count:1});return true}if(old.count>=RATE_MAX)return false;old.count++;return true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} @@ -26,7 +26,7 @@ function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude?? function distanceKm(a:any,b:any){const A=coords(a),B=coords(b);if(!A||!B)return undefined;const r=(v:number)=>v*Math.PI/180,dLat=r(B.lat-A.lat),dLon=r(B.lng-A.lng),h=Math.sin(dLat/2)**2+Math.cos(r(A.lat))*Math.cos(r(B.lat))*Math.sin(dLon/2)**2;return 6371*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h))} function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,longitude:l.lng}:null;if(!A||!B)return undefined;return distanceKm(A,B)} function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.city,p.area,p.service_area,p.address,p.landmark,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} -async function direct(type:string,ct?:string){const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return [];const rows=await db(`/rest/v1/${table}?select=*&limit=1000`);return (Array.isArray(rows)?rows:[]).map(p=>({...p,_type:type})).filter(p=>!ct||n(p.city||"").includes(n(ct)))} +async function direct(type:string,ct?:string){const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return [];const all:any[]=[];let offset=0;const pageSize=500;while(true){const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),offset:String(offset)});if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`);const rows=await db(`/rest/v1/${table}?${params.toString()}`);const page=Array.isArray(rows)?rows:[];all.push(...page.map(p=>({...p,_type:type})));if(page.length=10000)break}return all.filter(p=>!ct||n(p.city||"").includes(n(ct)))} async function multiDirect(types:string[],ct?:string){const unique=[...new Set(types.filter(Boolean))];const groups=await Promise.all(unique.map(type=>direct(type,ct)));return groups.flat()} function genderWanted(s:string){const x=n(s);if(/\b(girls|girl|female|ladki|ladkiyo|ladkiyon|महिला|लड़क|लड़की|girls hostel)\b/i.test(x))return "girls";if(/\b(boys|boy|male|ladke|ladkon|लड़के|लड़का|boys hostel)\b/i.test(x))return "boys";return ""} function areaWanted(s:string){const x=n(s);if(/nawalgarh road|nawalgarh rd|नवलगढ़ रोड/i.test(x))return "nawalgarh";if(/piprali road|piprali rd|पिपराली रोड/i.test(x))return "piprali";return ""} @@ -251,10 +251,13 @@ Deno.serve(async(req:Request)=>{ try{ if(!allowed(req))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}}); const contentLength=Number(req.headers.get("content-length")||0);if(contentLength>32768)return new Response(JSON.stringify({error:"Request is too large."}),{status:413,headers:H}); - const b=await req.json(),msg=String(b?.message||"").trim().slice(0,1500),history=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; + const rawBody=await req.text();if(new TextEncoder().encode(rawBody).byteLength>32768)return new Response(JSON.stringify({error:"Request is too large."}),{status:413,headers:H}); + let b:any;try{b=JSON.parse(rawBody)}catch{return new Response(JSON.stringify({error:"Invalid JSON request."}),{status:400,headers:H})} + const msg=String(b?.message||"").trim().slice(0,1500),history=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; if(!msg)return new Response(JSON.stringify({error:"Message is required"}),{status:400,headers:H}); const sessionId=String(b?.sessionId||crypto.randomUUID()); if(String(b?.action||"")==="submit_owner_lead"){ + if(!ownerLeadAllowed(req))return new Response(JSON.stringify({error:"Too many owner requests. Please try again later."}),{status:429,headers:{...H,"Retry-After":"600"}}); const lead=b?.ownerLead||{}; const name=String(lead.name||"").trim().replace(/\s+/g," ").slice(0,80); const phone=String(lead.phone||"").trim().slice(0,20); @@ -389,7 +392,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(u.budgetMax!==null||u.budgetMin!==null)rows=rows.filter(p=>{const v=budgetValue(p);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); if(u.facilities?.length)rows=rows.filter(p=>matchesFacility(p,u.facilities)); const landmark=u.landmark?findLandmark(u.landmark)||LANDMARKS.find(l=>l.key===u.landmark):findLandmark(near||""); - if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&(p._distanceKm===undefined||p._distanceKm<=10))} + if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||searchText(p).includes(n(landmark.key))))} else if(near){const q=n(near);const exact=rows.filter(p=>searchText(p).includes(q));if(exact.length)rows=exact;else if(!wantedArea)rows=[]} if(nearCats.length){const secondaryRows=await multiDirect(nearCats,ct);for(const p of rows){let best=Infinity,hasCoords=false;for(const q of secondaryRows){const d=distanceKm(p,q);if(d!==undefined){hasCoords=true;best=Math.min(best,d)}else if(p.area&&searchText(q).includes(n(p.area)))best=Math.min(best,1)}if(best<=3){p._nearbyDistanceKm=best;p._nearbyVerified=hasCoords;p._nearbyAreaMatch=!hasCoords}}rows=rows.filter(p=>p._nearbyDistanceKm!==undefined)} rows=rows.filter(p=>!shownIds.has(String(p.id))); From ea614ba262bcb7d252b668dd343c00a6d9d45c45 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:19:08 +0530 Subject: [PATCH 02/95] Remove duplicate chatbot admin controls instance --- admin/src/components/IntelligenceViews.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/components/IntelligenceViews.tsx b/admin/src/components/IntelligenceViews.tsx index 8da9732c7..6972dadca 100644 --- a/admin/src/components/IntelligenceViews.tsx +++ b/admin/src/components/IntelligenceViews.tsx @@ -1272,7 +1272,7 @@ export const IntelligenceViews: React.FC = ({

- + ); From d39861fd4e57d8471dc82e02a0ca3f2496cee249 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:19:37 +0530 Subject: [PATCH 03/95] Connect chatbot admin controls to live Edge Function --- .../functions/studenthubhelp-chat/index.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 796fcedff..fede20852 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -16,6 +16,10 @@ const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number function rateKey(req:Request){const f=req.headers.get("cf-connecting-ip")||req.headers.get("x-real-ip")||"unknown";return f.trim().slice(0,100)||"unknown"} function pruneRateMap(map:Map,now:number){if(map.size<5000)return;for(const [key,value] of map){if(now-value.start>Math.max(RATE_WINDOW_MS,OWNER_LEAD_WINDOW_MS))map.delete(key);if(map.size<4000)break}}\nfunction allowed(req:Request){const key=rateKey(req),now=Date.now(),old=rateMap.get(key);pruneRateMap(rateMap,now);if(!old||now-old.start>=RATE_WINDOW_MS){rateMap.set(key,{start:now,count:1});return true}if(old.count>=RATE_MAX)return false;old.count++;return true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} +type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; +const DEFAULT_RUNTIME_CONFIG:ChatbotRuntimeConfig={enabled:true,ai_rerank_enabled:true,lead_capture_enabled:true,comparison_enabled:true,budget_planner_enabled:true,max_candidates:40,max_results:8,max_output_tokens:900,temperature:0.2,maintenance_message:""}; +let runtimeConfigCache:{value:ChatbotRuntimeConfig;expiresAt:number}|null=null; +async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0]||{};const value:ChatbotRuntimeConfig={enabled:row.enabled!==false,ai_rerank_enabled:row.ai_rerank_enabled!==false,lead_capture_enabled:row.lead_capture_enabled!==false,comparison_enabled:row.comparison_enabled!==false,budget_planner_enabled:row.budget_planner_enabled!==false,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+15000};return value}catch(e){console.error("chatbot admin config unavailable; using safe defaults",e);return DEFAULT_RUNTIME_CONFIG}} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} function findLandmark(s:string){const x=n(s);for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} @@ -111,7 +115,7 @@ function strictLocation(s:string){ if(d.city||d.locality||d.area||d.landmark)return d; const c=city(s);return c?{city:c}:{}; } -async function aiUnderstand(msg:string,history:any[]){ +async function aiUnderstand(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG){ const fallback=offlineIntent(msg,history);if(!GEMINI)return fallback; try{ const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"").slice(0,1200)}`).join("\n"); @@ -133,7 +137,7 @@ CURRENT MESSAGE: ${msg} RECENT CONVERSATION: ${h}`; - const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:700,thinkingConfig:{thinkingLevel:"medium"}}})}); + const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:Math.min(900,cfg.max_output_tokens),temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}})}); if(!r.ok)return fallback;const j=await r.json(),txt=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";return normalizeIntent(safeJson(txt)); }catch{return fallback} } @@ -159,7 +163,7 @@ function mergeIntent(ai:any,msg:string,history:any[]){ function budgetValue(p:any){return num(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price)} function matchesFacility(p:any,wanted:string[]){if(!wanted.length)return true;const z=searchText(p);return wanted.every(f=>z.includes(n(f))||Object.entries(p).some(([k,v])=>n(k).includes(n(f))&&(v===true||String(v).toLowerCase()==="true"||n(v).includes(n(f)))))} -async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string){if(!GEMINI)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. +async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string,cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG){if(!GEMINI||!cfg.ai_rerank_enabled)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. Match the user's language: natural Hinglish/Hindi/English. Be warm, concise, clear and impressive. Current user: ${msg} @@ -182,7 +186,7 @@ Rules: - Never recommend listings unless the current message has a property-search intent. - Do not expose internal system/tool details. Return only the natural reply text.`; -const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:350,thinkingConfig:{thinkingLevel:"low"}}})});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}catch{return{}}} +const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:Math.min(700,cfg.max_output_tokens),temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"low"}}})});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}catch{return{}}} async function dbWrite(method:string,path:string,body:any,prefer="return=minimal"){ if(!U||!K)throw Error("database configuration missing"); @@ -233,7 +237,7 @@ function offlineGeneralReply(msg:string){ if(/hello|hi|hey|namaste|kaise ho|kya haal/i.test(x))return "Namaste 😊 Main yahin hoon—study questions, general knowledge, ideas ya StudentHubHelp listings mein help kar sakta hoon. Aaj kya karna hai?"; return "Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊"; } -async function aiGeneral(msg:string,history:any[]){const offline=offlineGeneralReply(msg);if(!GEMINI||offline!=="Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊")return{reply:offline,webGrounded:false,webSources:[]};try{const h=history.slice(-8).map(x=>({role:x.role==="assistant"?"model":"user",parts:[{text:String(x.text||"").slice(0,1500)}]}));const system=`You are StudentHubHelp's Ultra Advance AI Assistant for students. +async function aiGeneral(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG){const offline=offlineGeneralReply(msg);if(!GEMINI||offline!=="Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊")return{reply:offline,webGrounded:false,webSources:[]};try{const h=history.slice(-8).map(x=>({role:x.role==="assistant"?"model":"user",parts:[{text:String(x.text||"").slice(0,1500)}]}));const system=`You are StudentHubHelp's Ultra Advance AI Assistant for students. You are a natural conversational, study and general knowledge assistant. Match the user's language and tone: Hindi, Hinglish or English. You can discuss normal life, boredom, tiredness, motivation, hobbies and casual topics in a friendly respectful way. @@ -243,7 +247,7 @@ Do not claim StudentHubHelp property facts here. Property data belongs to the se Do not pretend to know personal facts about the user. Keep normal chat concise and human; for study questions be clear and useful. Current message: ${msg}`; -const body:any={contents:[...h,{role:"user",parts:[{text:system}]}],generationConfig:{maxOutputTokens:700,thinkingConfig:{thinkingLevel:"medium"}}};if(wantsCurrentWeb(msg))body.tools=[{google_search:{}}];const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify(body)});if(!r.ok)return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]};const j=await r.json();const reply=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";const chunks=j?.candidates?.[0]?.groundingMetadata?.groundingChunks||[];const sources=chunks.map((x:any)=>x?.web?.uri&&{url:x.web.uri,title:x.web.title||x.web.uri}).filter(Boolean).slice(0,5);return{reply:reply||offlineGeneralReply(msg),webGrounded:sources.length>0,webSources:sources}}catch{return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]}}} +const body:any={contents:[...h,{role:"user",parts:[{text:system}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}};if(wantsCurrentWeb(msg))body.tools=[{google_search:{}}];const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify(body)});if(!r.ok)return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]};const j=await r.json();const reply=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";const chunks=j?.candidates?.[0]?.groundingMetadata?.groundingChunks||[];const sources=chunks.map((x:any)=>x?.web?.uri&&{url:x.web.uri,title:x.web.title||x.web.uri}).filter(Boolean).slice(0,5);return{reply:reply||offlineGeneralReply(msg),webGrounded:sources.length>0,webSources:sources}}catch{return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]}}} Deno.serve(async(req:Request)=>{ if(req.method==="OPTIONS")return new Response("ok",{headers:H}); @@ -256,7 +260,10 @@ Deno.serve(async(req:Request)=>{ const msg=String(b?.message||"").trim().slice(0,1500),history=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; if(!msg)return new Response(JSON.stringify({error:"Message is required"}),{status:400,headers:H}); const sessionId=String(b?.sessionId||crypto.randomUUID()); + const cfg=await getRuntimeConfig(); + if(!cfg.enabled)return new Response(JSON.stringify({reply:cfg.maintenance_message||"StudentHubHelp AI assistant is temporarily unavailable. Please try again shortly.",grounded:false,maintenance:true}),{status:503,headers:H}); if(String(b?.action||"")==="submit_owner_lead"){ + if(!cfg.lead_capture_enabled)return new Response(JSON.stringify({error:"Owner listing requests are temporarily disabled."}),{status:403,headers:H}); if(!ownerLeadAllowed(req))return new Response(JSON.stringify({error:"Too many owner requests. Please try again later."}),{status:429,headers:{...H,"Retry-After":"600"}}); const lead=b?.ownerLead||{}; const name=String(lead.name||"").trim().replace(/\s+/g," ").slice(0,80); @@ -293,14 +300,14 @@ Deno.serve(async(req:Request)=>{ const reply=validPhone?"Ji 😊 **"+fp.name+"** ka listed phone number: **"+validPhone+"**":"Is listing ka valid phone number abhi StudentHubHelp data me available nahi hai."; return await chatResponse({reply,intent:"Property Contact",primaryTopic:label(focusType)+" contact",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iske full details batao","Allen ke paas aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } - const ar=await aiProperty(msg,history,[fp],focusType,nearTerm(focusCombined)); + const ar=await aiProperty(msg,history,[fp],focusType,nearTerm(focusCombined),cfg); const reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain."); return await chatResponse({reply,intent:"Property Details",primaryTopic:label(focusType)+" details",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Allen ke paas aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } } if(isAccountSupport(msg))return await chatResponse({reply:accountSupportReply(msg),intent:"Account / Login Support",primaryTopic:"Account access",suggestedFollowUps:["I forgot my password","Google login is not working","Dashboard is not opening","Contact support"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); if(isContact(msg)||String(b?.action||"")==="contact_support")return await chatResponse({reply:"Ji bilkul 😊 StudentHubHelp team se contact ke liye:\n\n📧 Email: studenthubhelpp@gmail.com\n📞 Phone: +91 9929718264",intent:"Support / Contact",primaryTopic:"Contact StudentHubHelp",suggestedFollowUps:["Find a hostel","Find a library","Search by area","Back to search"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); - const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),aiCats=u.categories||[],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); + const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),aiCats=u.categories||[],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); if(action==="search_area"&&!city(msg)&&!nearTerm(msg))return await chatResponse({reply:"Bilkul 😊 Aap kis **area / locality** mein search karna chahte hain? Area ka naam bhejiye, main current requirement ke saath search refine kar dunga.",intent:"Area Refinement",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by city","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.needsClarification&&u.clarificationQuestion&&!u.category&&!u.categories.length&&!u.locality&&!u.area&&!u.landmark)return await chatResponse({reply:u.clarificationQuestion,intent:"Clarification",primaryTopic:"Requirement clarification",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a hostel","Find a library","Find a cafe","Find a tiffin service"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.intent==="property_reference"&&u.referenceIndex){ @@ -310,7 +317,7 @@ Deno.serve(async(req:Request)=>{ if(target){ const fp=card(target),wantPhone=u.action==="phone"||isPhoneIntent(msg); if(wantPhone){const ph=String(fp.phone||"").trim();const reply=ph&&ph.replace(/\\D/g,"").length>=7?"Ji 😊 **"+fp.name+"** ka listed phone number: **"+ph+"**":"Is listing ka valid phone number abhi StudentHubHelp data me available nahi hai.";return await chatResponse({reply,intent:"Property Contact",primaryTopic:"Referenced property contact",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iske full details batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg)} - const ar=await aiProperty(msg,history,[fp],fp._type,near),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) + const ar=await aiProperty(msg,history,[fp],fp._type,near,cfg),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) } } if(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg))&&!isBookingInfoQuestion(msg)){ @@ -320,7 +327,7 @@ Deno.serve(async(req:Request)=>{ let decision=decisionRows.filter(p=>!shownIds.has(String(p.id))); if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&(p._distanceKm===undefined||p._distanceKm<=10)); const rankedDecision=decision.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,8).map(card); - const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near); + const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near,cfg); const reply=ar.reply||"Aapki requirement ke hisaab se live options compare kar sakte hain."; return await chatResponse({reply,intent:"Property Comparison",primaryTopic:label(c||"hostel")+" comparison",recommendedProperties:rankedDecision,recommendations:rankedDecision,suggestedFollowUps:["Boys hostel dikhao","Girls hostel dikhao","Budget ke according dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } @@ -378,7 +385,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(isPropertyAdviceQuestion(msg)){const adviceCat=cat(msg)||cat(previous)||"hostel";return await chatResponse({reply:adviceReply(adviceCat),intent:"Property Selection Guidance",primaryTopic:label(adviceCat)+" selection guidance",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a property","Search by area","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} - if(!propertyMode){const ar=await aiGeneral(msg,history);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} + if(!propertyMode){const ar=await aiGeneral(msg,history,cfg);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct); const wantedGender=u.gender||genderWanted(msg),wantedArea=u.area?(/nawalgarh/i.test(u.area)?"nawalgarh":/piprali/i.test(u.area)?"piprali":u.area.toLowerCase()):areaWanted(msg); const wantedLocality=n(u.locality||""); @@ -397,9 +404,9 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(nearCats.length){const secondaryRows=await multiDirect(nearCats,ct);for(const p of rows){let best=Infinity,hasCoords=false;for(const q of secondaryRows){const d=distanceKm(p,q);if(d!==undefined){hasCoords=true;best=Math.min(best,d)}else if(p.area&&searchText(q).includes(n(p.area)))best=Math.min(best,1)}if(best<=3){p._nearbyDistanceKm=best;p._nearbyVerified=hasCoords;p._nearbyAreaMatch=!hasCoords}}rows=rows.filter(p=>p._nearbyDistanceKm!==undefined)} rows=rows.filter(p=>!shownIds.has(String(p.id))); const ranked=rows.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score); - let recs=ranked.slice(0,8).map(card); + let recs=ranked.slice(0,cfg.max_results).map(card); if(action==="view_details"){const focusId=String(b?.focusPropertyId||""),focusType=String(b?.focusPropertyType||c||"hostel"),focusRows=await multiDirect([focusType],ct),focus=focusRows.find(p=>String(p.id)===focusId);recs=focus?[card(focus)]:recs.slice(0,1)} - const ar=await aiProperty(msg,history,recs,c,near),reply=ar.reply||fallbackReply(c,recs,near,ct),followups=["View full details","Search by area","Show more options","Contact support"]; + const ar=await aiProperty(msg,history,recs,c,near,cfg),reply=ar.reply||fallbackReply(c,recs,near,ct),followups=["View full details","Search by area","Show more options","Contact support"]; return await chatResponse({reply,intent:"Live Property Search",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:recs,recommendations:recs,suggestedFollowUps:followups,grounded:true,sessionId,searchMode:"ultra_advance_live_supabase_ai",targetCity:ct||undefined,targetCategory:c||undefined,nearbyTerm:near||undefined,aiIntent:u.intent,aiConfidence:u.confidence,aiEntities:{category:u.category,categories:u.categories,city:u.city,locality:u.locality,area:u.area,landmark:u.landmark,gender:u.gender,budgetMin:u.budgetMin,budgetMax:u.budgetMax,facilities:u.facilities,referenceIndex:u.referenceIndex}},{headers:H},sessionId,msg); }catch(e){return new Response(JSON.stringify({reply:"Ji, live search me temporary issue aaya. Please same query dobara bhejiye.",grounded:false,error:"Temporary server error"}),{status:200,headers:H})} }); \ No newline at end of file From 8e3ad2551abc490014b75818caa2b7fe306e031f Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:19:59 +0530 Subject: [PATCH 04/95] Apply chatbot comparison and retrieval limits --- supabase/functions/studenthubhelp-chat/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index fede20852..c0493b72a 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -320,13 +320,13 @@ Deno.serve(async(req:Request)=>{ const ar=await aiProperty(msg,history,[fp],fp._type,near,cfg),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) } } - if(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg))&&!isBookingInfoQuestion(msg)){ + if(cfg.comparison_enabled&&/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg))&&!isBookingInfoQuestion(msg)){ const decisionRows=await multiDirect([c||"hostel"],ct); const decisionLandmark=findLandmark(near||""); if(decisionLandmark){for(const p of decisionRows){const d=distanceToLandmark(p,decisionLandmark);if(d!==undefined)p._distanceKm=d}} let decision=decisionRows.filter(p=>!shownIds.has(String(p.id))); - if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&(p._distanceKm===undefined||p._distanceKm<=10)); - const rankedDecision=decision.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,8).map(card); + if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||searchText(p).includes(n(decisionLandmark.key)))); + const rankedDecision=decision.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_results).map(card); const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near,cfg); const reply=ar.reply||"Aapki requirement ke hisaab se live options compare kar sakte hain."; return await chatResponse({reply,intent:"Property Comparison",primaryTopic:label(c||"hostel")+" comparison",recommendedProperties:rankedDecision,recommendations:rankedDecision,suggestedFollowUps:["Boys hostel dikhao","Girls hostel dikhao","Budget ke according dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); @@ -403,7 +403,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det else if(near){const q=n(near);const exact=rows.filter(p=>searchText(p).includes(q));if(exact.length)rows=exact;else if(!wantedArea)rows=[]} if(nearCats.length){const secondaryRows=await multiDirect(nearCats,ct);for(const p of rows){let best=Infinity,hasCoords=false;for(const q of secondaryRows){const d=distanceKm(p,q);if(d!==undefined){hasCoords=true;best=Math.min(best,d)}else if(p.area&&searchText(q).includes(n(p.area)))best=Math.min(best,1)}if(best<=3){p._nearbyDistanceKm=best;p._nearbyVerified=hasCoords;p._nearbyAreaMatch=!hasCoords}}rows=rows.filter(p=>p._nearbyDistanceKm!==undefined)} rows=rows.filter(p=>!shownIds.has(String(p.id))); - const ranked=rows.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score); + const ranked=rows.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_candidates); let recs=ranked.slice(0,cfg.max_results).map(card); if(action==="view_details"){const focusId=String(b?.focusPropertyId||""),focusType=String(b?.focusPropertyType||c||"hostel"),focusRows=await multiDirect([focusType],ct),focus=focusRows.find(p=>String(p.id)===focusId);recs=focus?[card(focus)]:recs.slice(0,1)} const ar=await aiProperty(msg,history,recs,c,near,cfg),reply=ar.reply||fallbackReply(c,recs,near,ct),followups=["View full details","Search by area","Show more options","Contact support"]; From 835c93062fef4db6d3b51dee947ad76a6c233356 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:20:27 +0530 Subject: [PATCH 05/95] Harden chatbot admin feature gates and background logging --- supabase/functions/studenthubhelp-chat/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index c0493b72a..667e009b3 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -224,7 +224,7 @@ async function logChatTurn(sessionId:string,userMessage:string,payload:any){ } } async function chatResponse(payload:any,init:any,sessionId:string,userMessage:string){ - void logChatTurn(sessionId,userMessage,payload); + EdgeRuntime.waitUntil(logChatTurn(sessionId,userMessage,payload)); return new Response(JSON.stringify(payload),init); } function offlineGeneralReply(msg:string){ @@ -320,7 +320,7 @@ Deno.serve(async(req:Request)=>{ const ar=await aiProperty(msg,history,[fp],fp._type,near,cfg),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) } } - if(cfg.comparison_enabled&&/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg))&&!isBookingInfoQuestion(msg)){ + if(cfg.comparison_enabled&&(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg)))&&!isBookingInfoQuestion(msg)){ const decisionRows=await multiDirect([c||"hostel"],ct); const decisionLandmark=findLandmark(near||""); if(decisionLandmark){for(const p of decisionRows){const d=distanceToLandmark(p,decisionLandmark);if(d!==undefined)p._distanceKm=d}} @@ -384,7 +384,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det } if(isPropertyAdviceQuestion(msg)){const adviceCat=cat(msg)||cat(previous)||"hostel";return await chatResponse({reply:adviceReply(adviceCat),intent:"Property Selection Guidance",primaryTopic:label(adviceCat)+" selection guidance",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a property","Search by area","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg);} - if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} + if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(!propertyMode){const ar=await aiGeneral(msg,history,cfg);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct); const wantedGender=u.gender||genderWanted(msg),wantedArea=u.area?(/nawalgarh/i.test(u.area)?"nawalgarh":/piprali/i.test(u.area)?"piprali":u.area.toLowerCase()):areaWanted(msg); From 9238ca3c827fbd46d53ad3694a142f547fb052bc Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:21:03 +0530 Subject: [PATCH 06/95] Tighten chatbot rate-limit identity and config bounds --- supabase/functions/studenthubhelp-chat/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 667e009b3..1b8da3275 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -8,12 +8,12 @@ const RATE_WINDOW_MS=60_000, RATE_MAX=30; const rateMap=new Map(); const ownerLeadRateMap=new Map(); const OWNER_LEAD_WINDOW_MS=10*60_000, OWNER_LEAD_MAX=3; -function ownerLeadAllowed(req:Request){const key=rateKey(req),now=Date.now(),old=ownerLeadRateMap.get(key);if(!old||now-old.start>=OWNER_LEAD_WINDOW_MS){ownerLeadRateMap.set(key,{start:now,count:1});return true}if(old.count>=OWNER_LEAD_MAX)return false;old.count++;return true} +function ownerLeadAllowed(req:Request){const key=rateKey(req),now=Date.now(),old=ownerLeadRateMap.get(key);pruneRateMap(ownerLeadRateMap,now);if(!old||now-old.start>=OWNER_LEAD_WINDOW_MS){ownerLeadRateMap.set(key,{start:now,count:1});return true}if(old.count>=OWNER_LEAD_MAX)return false;old.count++;return true} const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"authorization,x-client-info,apikey,content-type","Access-Control-Allow-Methods":"POST,OPTIONS","Content-Type":"application/json"}; const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKey(req:Request){const f=req.headers.get("cf-connecting-ip")||req.headers.get("x-real-ip")||"unknown";return f.trim().slice(0,100)||"unknown"} +function rateKey(req:Request){const f=req.headers.get("cf-connecting-ip")||"unknown";return f.trim().slice(0,100)||"unknown"} function pruneRateMap(map:Map,now:number){if(map.size<5000)return;for(const [key,value] of map){if(now-value.start>Math.max(RATE_WINDOW_MS,OWNER_LEAD_WINDOW_MS))map.delete(key);if(map.size<4000)break}}\nfunction allowed(req:Request){const key=rateKey(req),now=Date.now(),old=rateMap.get(key);pruneRateMap(rateMap,now);if(!old||now-old.start>=RATE_WINDOW_MS){rateMap.set(key,{start:now,count:1});return true}if(old.count>=RATE_MAX)return false;old.count++;return true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; @@ -137,7 +137,7 @@ CURRENT MESSAGE: ${msg} RECENT CONVERSATION: ${h}`; - const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:Math.min(900,cfg.max_output_tokens),temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}})}); + const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}})}); if(!r.ok)return fallback;const j=await r.json(),txt=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";return normalizeIntent(safeJson(txt)); }catch{return fallback} } @@ -186,7 +186,7 @@ Rules: - Never recommend listings unless the current message has a property-search intent. - Do not expose internal system/tool details. Return only the natural reply text.`; -const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:Math.min(700,cfg.max_output_tokens),temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"low"}}})});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}catch{return{}}} +const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"low"}}})});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}catch{return{}}} async function dbWrite(method:string,path:string,body:any,prefer="return=minimal"){ if(!U||!K)throw Error("database configuration missing"); From 06c0a498292628e196bfb140700db7f04eed8862 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:25:54 +0530 Subject: [PATCH 07/95] Add atomic distributed chatbot rate limiting --- ...8190000_chatbot_distributed_rate_limit.sql | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql diff --git a/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql b/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql new file mode 100644 index 000000000..0abaf161a --- /dev/null +++ b/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql @@ -0,0 +1,36 @@ +-- Durable, atomic rate limiting for the StudentHubHelp chatbot. +create table if not exists private.chatbot_rate_limits ( + scope text not null, + key_hash text not null, + window_started_at timestamptz not null, + hit_count integer not null default 0, + primary key (scope, key_hash) +); +revoke all on table private.chatbot_rate_limits from public, anon, authenticated; +grant all on table private.chatbot_rate_limits to service_role; + +create or replace function public.consume_chatbot_rate_limit( + p_scope text, p_key text, p_limit integer, p_window_seconds integer +) returns boolean +language plpgsql security definer set search_path = '' +as $$ +declare v_count integer; +begin + if p_scope not in ('chat', 'owner_lead') or p_key is null or length(p_key)=0 + or p_limit < 1 or p_limit > 1000 or p_window_seconds < 1 or p_window_seconds > 86400 then + raise exception 'Invalid rate limit arguments'; + end if; + insert into private.chatbot_rate_limits(scope,key_hash,window_started_at,hit_count) + values(p_scope,md5(p_key),now(),1) + on conflict(scope,key_hash) do update + set window_started_at=case when private.chatbot_rate_limits.window_started_at <= now()-make_interval(secs=>p_window_seconds) then now() else private.chatbot_rate_limits.window_started_at end, + hit_count=case when private.chatbot_rate_limits.window_started_at <= now()-make_interval(secs=>p_window_seconds) then 1 else private.chatbot_rate_limits.hit_count+1 end + returning hit_count into v_count; + if mod(extract(epoch from now())::bigint,100)=0 then + delete from private.chatbot_rate_limits where window_started_at < now()-interval '2 days'; + end if; + return v_count <= p_limit; +end; +$$; +revoke all on function public.consume_chatbot_rate_limit(text,text,integer,integer) from public,anon,authenticated; +grant execute on function public.consume_chatbot_rate_limit(text,text,integer,integer) to service_role; From cd0da17ab6de4f8c71c06815006f9bc82c9da108 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:25:57 +0530 Subject: [PATCH 08/95] Fix chatbot syntax and use distributed rate limits --- supabase/functions/studenthubhelp-chat/index.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 1b8da3275..6a6f9f485 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -4,17 +4,12 @@ const U=(Deno.env.get("SUPABASE_URL")||"").replace(/\/$/,""); const K=Deno.env.get("SUPABASE_SECRET_KEY")||Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")||""; const GEMINI=Deno.env.get("GEMINI_API_KEY")||""; const MODEL="gemini-3.8-flash"; -const RATE_WINDOW_MS=60_000, RATE_MAX=30; -const rateMap=new Map(); -const ownerLeadRateMap=new Map(); -const OWNER_LEAD_WINDOW_MS=10*60_000, OWNER_LEAD_MAX=3; -function ownerLeadAllowed(req:Request){const key=rateKey(req),now=Date.now(),old=ownerLeadRateMap.get(key);pruneRateMap(ownerLeadRateMap,now);if(!old||now-old.start>=OWNER_LEAD_WINDOW_MS){ownerLeadRateMap.set(key,{start:now,count:1});return true}if(old.count>=OWNER_LEAD_MAX)return false;old.count++;return true} const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"authorization,x-client-info,apikey,content-type","Access-Control-Allow-Methods":"POST,OPTIONS","Content-Type":"application/json"}; const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKey(req:Request){const f=req.headers.get("cf-connecting-ip")||"unknown";return f.trim().slice(0,100)||"unknown"} -function pruneRateMap(map:Map,now:number){if(map.size<5000)return;for(const [key,value] of map){if(now-value.start>Math.max(RATE_WINDOW_MS,OWNER_LEAD_WINDOW_MS))map.delete(key);if(map.size<4000)break}}\nfunction allowed(req:Request){const key=rateKey(req),now=Date.now(),old=rateMap.get(key);pruneRateMap(rateMap,now);if(!old||now-old.start>=RATE_WINDOW_MS){rateMap.set(key,{start:now,count:1});return true}if(old.count>=RATE_MAX)return false;old.count++;return true} +function rateKey(req:Request,sessionId:string){const ip=req.headers.get("cf-connecting-ip")||req.headers.get("x-real-ip");if(ip?.trim())return ip.trim().slice(0,100);const ua=req.headers.get("user-agent")||"unknown";return ("session:"+sessionId+":"+ua).slice(0,220)} +async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; const DEFAULT_RUNTIME_CONFIG:ChatbotRuntimeConfig={enabled:true,ai_rerank_enabled:true,lead_capture_enabled:true,comparison_enabled:true,budget_planner_enabled:true,max_candidates:40,max_results:8,max_output_tokens:900,temperature:0.2,maintenance_message:""}; @@ -253,18 +248,18 @@ Deno.serve(async(req:Request)=>{ if(req.method==="OPTIONS")return new Response("ok",{headers:H}); if(req.method!=="POST")return new Response(JSON.stringify({error:"POST required"}),{status:405,headers:H}); try{ - if(!allowed(req))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}}); const contentLength=Number(req.headers.get("content-length")||0);if(contentLength>32768)return new Response(JSON.stringify({error:"Request is too large."}),{status:413,headers:H}); const rawBody=await req.text();if(new TextEncoder().encode(rawBody).byteLength>32768)return new Response(JSON.stringify({error:"Request is too large."}),{status:413,headers:H}); let b:any;try{b=JSON.parse(rawBody)}catch{return new Response(JSON.stringify({error:"Invalid JSON request."}),{status:400,headers:H})} const msg=String(b?.message||"").trim().slice(0,1500),history=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; if(!msg)return new Response(JSON.stringify({error:"Message is required"}),{status:400,headers:H}); const sessionId=String(b?.sessionId||crypto.randomUUID()); + try{if(!await consumeRateLimit("chat",rateKey(req,sessionId),30,60))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Chat service temporarily unavailable."}),{status:503,headers:H})} const cfg=await getRuntimeConfig(); if(!cfg.enabled)return new Response(JSON.stringify({reply:cfg.maintenance_message||"StudentHubHelp AI assistant is temporarily unavailable. Please try again shortly.",grounded:false,maintenance:true}),{status:503,headers:H}); if(String(b?.action||"")==="submit_owner_lead"){ if(!cfg.lead_capture_enabled)return new Response(JSON.stringify({error:"Owner listing requests are temporarily disabled."}),{status:403,headers:H}); - if(!ownerLeadAllowed(req))return new Response(JSON.stringify({error:"Too many owner requests. Please try again later."}),{status:429,headers:{...H,"Retry-After":"600"}}); + try{if(!await consumeRateLimit("owner_lead",rateKey(req,sessionId),3,600))return new Response(JSON.stringify({error:"Too many owner requests. Please try again later."}),{status:429,headers:{...H,"Retry-After":"600"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Owner request service temporarily unavailable."}),{status:503,headers:H})} const lead=b?.ownerLead||{}; const name=String(lead.name||"").trim().replace(/\s+/g," ").slice(0,80); const phone=String(lead.phone||"").trim().slice(0,20); From d8b5c9d55f2cc5644395a5f82b464e405bedefe3 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:26:24 +0530 Subject: [PATCH 09/95] Honor chatbot budget planner admin control --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 6a6f9f485..dfa3be089 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -391,7 +391,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(wantedLocality)rows=rows.filter(p=>searchText(p).includes(wantedLocality)); // An explicit road/locality is also a hard constraint when the user asked for it. if(locationForFilter.area)rows=rows.filter(p=>searchText(p).includes(n(locationForFilter.area))); - if(u.budgetMax!==null||u.budgetMin!==null)rows=rows.filter(p=>{const v=budgetValue(p);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); + if(cfg.budget_planner_enabled&&(u.budgetMax!==null||u.budgetMin!==null))rows=rows.filter(p=>{const v=budgetValue(p);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); if(u.facilities?.length)rows=rows.filter(p=>matchesFacility(p,u.facilities)); const landmark=u.landmark?findLandmark(u.landmark)||LANDMARKS.find(l=>l.key===u.landmark):findLandmark(near||""); if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||searchText(p).includes(n(landmark.key))))} From e11e4c45bdf0ca637eb1870834b2f5b289968e68 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:26:27 +0530 Subject: [PATCH 10/95] Clarify chatbot AI control label --- admin/src/components/ChatbotAdminControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/components/ChatbotAdminControls.tsx b/admin/src/components/ChatbotAdminControls.tsx index 7d0756a38..d403aa83c 100644 --- a/admin/src/components/ChatbotAdminControls.tsx +++ b/admin/src/components/ChatbotAdminControls.tsx @@ -241,7 +241,7 @@ export const ChatbotAdminControls: React.FC = ({
{[ ['enabled', 'AI assistant enabled', ShieldCheck], - ['ai_rerank_enabled', 'Gemini AI reranking', Brain], + ['ai_rerank_enabled', 'Gemini AI response enhancement', Brain], ['lead_capture_enabled', 'Lead capture', SlidersHorizontal], ['comparison_enabled', 'Live comparison mode', SlidersHorizontal], ['budget_planner_enabled', 'Live budget planner', SlidersHorizontal], From d86fab143c447b70f3c3ac729215390417a46f14 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:35:48 +0530 Subject: [PATCH 11/95] Harden chatbot runtime config, rate limits and error handling --- supabase/functions/studenthubhelp-chat/index.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index dfa3be089..53ff0034e 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -8,13 +8,14 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKey(req:Request,sessionId:string){const ip=req.headers.get("cf-connecting-ip")||req.headers.get("x-real-ip");if(ip?.trim())return ip.trim().slice(0,100);const ua=req.headers.get("user-agent")||"unknown";return ("session:"+sessionId+":"+ua).slice(0,220)} +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const ipKey=cfIp?("ip:"+cfIp.slice(0,100)):"ip:unavailable";const sessionKey=("session:"+String(sessionId||"missing").slice(0,160));return [{scopeKey:ipKey,limit:30,windowSeconds:60},{scopeKey:sessionKey,limit:15,windowSeconds:60}]}; +async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; -const DEFAULT_RUNTIME_CONFIG:ChatbotRuntimeConfig={enabled:true,ai_rerank_enabled:true,lead_capture_enabled:true,comparison_enabled:true,budget_planner_enabled:true,max_candidates:40,max_results:8,max_output_tokens:900,temperature:0.2,maintenance_message:""}; +const SAFE_FALLBACK_CONFIG:ChatbotRuntimeConfig={enabled:false,ai_rerank_enabled:false,lead_capture_enabled:false,comparison_enabled:false,budget_planner_enabled:false,max_candidates:10,max_results:1,max_output_tokens:200,temperature:0,maintenance_message:"Chatbot system maintenance par hai. Kripya thodi der baad koshish karein ya seedhe support par sampark karein."}; let runtimeConfigCache:{value:ChatbotRuntimeConfig;expiresAt:number}|null=null; -async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0]||{};const value:ChatbotRuntimeConfig={enabled:row.enabled!==false,ai_rerank_enabled:row.ai_rerank_enabled!==false,lead_capture_enabled:row.lead_capture_enabled!==false,comparison_enabled:row.comparison_enabled!==false,budget_planner_enabled:row.budget_planner_enabled!==false,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+15000};return value}catch(e){console.error("chatbot admin config unavailable; using safe defaults",e);return DEFAULT_RUNTIME_CONFIG}} +async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+15000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);return SAFE_FALLBACK_CONFIG}} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} function findLandmark(s:string){const x=n(s);for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} @@ -254,12 +255,12 @@ Deno.serve(async(req:Request)=>{ const msg=String(b?.message||"").trim().slice(0,1500),history=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; if(!msg)return new Response(JSON.stringify({error:"Message is required"}),{status:400,headers:H}); const sessionId=String(b?.sessionId||crypto.randomUUID()); - try{if(!await consumeRateLimit("chat",rateKey(req,sessionId),30,60))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Chat service temporarily unavailable."}),{status:503,headers:H})} + try{if(!await consumeDualRateLimit("chat",req,sessionId,30,60))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Chat service temporarily unavailable."}),{status:503,headers:H})} const cfg=await getRuntimeConfig(); if(!cfg.enabled)return new Response(JSON.stringify({reply:cfg.maintenance_message||"StudentHubHelp AI assistant is temporarily unavailable. Please try again shortly.",grounded:false,maintenance:true}),{status:503,headers:H}); if(String(b?.action||"")==="submit_owner_lead"){ if(!cfg.lead_capture_enabled)return new Response(JSON.stringify({error:"Owner listing requests are temporarily disabled."}),{status:403,headers:H}); - try{if(!await consumeRateLimit("owner_lead",rateKey(req,sessionId),3,600))return new Response(JSON.stringify({error:"Too many owner requests. Please try again later."}),{status:429,headers:{...H,"Retry-After":"600"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Owner request service temporarily unavailable."}),{status:503,headers:H})} + try{if(!await consumeDualRateLimit("owner_lead",req,sessionId,3,600))return new Response(JSON.stringify({error:"Too many owner requests. Please try again later."}),{status:429,headers:{...H,"Retry-After":"600"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Owner request service temporarily unavailable."}),{status:503,headers:H})} const lead=b?.ownerLead||{}; const name=String(lead.name||"").trim().replace(/\s+/g," ").slice(0,80); const phone=String(lead.phone||"").trim().slice(0,20); @@ -391,7 +392,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(wantedLocality)rows=rows.filter(p=>searchText(p).includes(wantedLocality)); // An explicit road/locality is also a hard constraint when the user asked for it. if(locationForFilter.area)rows=rows.filter(p=>searchText(p).includes(n(locationForFilter.area))); - if(cfg.budget_planner_enabled&&(u.budgetMax!==null||u.budgetMin!==null))rows=rows.filter(p=>{const v=budgetValue(p);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); + if(u.budgetMax!==null||u.budgetMin!==null)rows=rows.filter(p=>{const v=budgetValue(p);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); if(u.facilities?.length)rows=rows.filter(p=>matchesFacility(p,u.facilities)); const landmark=u.landmark?findLandmark(u.landmark)||LANDMARKS.find(l=>l.key===u.landmark):findLandmark(near||""); if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||searchText(p).includes(n(landmark.key))))} @@ -403,5 +404,5 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(action==="view_details"){const focusId=String(b?.focusPropertyId||""),focusType=String(b?.focusPropertyType||c||"hostel"),focusRows=await multiDirect([focusType],ct),focus=focusRows.find(p=>String(p.id)===focusId);recs=focus?[card(focus)]:recs.slice(0,1)} const ar=await aiProperty(msg,history,recs,c,near,cfg),reply=ar.reply||fallbackReply(c,recs,near,ct),followups=["View full details","Search by area","Show more options","Contact support"]; return await chatResponse({reply,intent:"Live Property Search",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:recs,recommendations:recs,suggestedFollowUps:followups,grounded:true,sessionId,searchMode:"ultra_advance_live_supabase_ai",targetCity:ct||undefined,targetCategory:c||undefined,nearbyTerm:near||undefined,aiIntent:u.intent,aiConfidence:u.confidence,aiEntities:{category:u.category,categories:u.categories,city:u.city,locality:u.locality,area:u.area,landmark:u.landmark,gender:u.gender,budgetMin:u.budgetMin,budgetMax:u.budgetMax,facilities:u.facilities,referenceIndex:u.referenceIndex}},{headers:H},sessionId,msg); - }catch(e){return new Response(JSON.stringify({reply:"Ji, live search me temporary issue aaya. Please same query dobara bhejiye.",grounded:false,error:"Temporary server error"}),{status:200,headers:H})} + }catch(e){const errorId=crypto.randomUUID();console.error(`[ERROR_REF:${errorId}] Internal chatbot failure`,{message:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:undefined,timestamp:new Date().toISOString()});return new Response(JSON.stringify({success:false,reply:"Server par temporary connectivity issue aaya hai. Kripya 1 minute baad punah prayas karein.",error:"Temporary server error",error_id:errorId,can_retry:true,grounded:false}),{status:500,headers:{...H,"Retry-After":"60"}})} }); \ No newline at end of file From a8cc46bfe59cb82a688834c498c46c23b850851d Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:35:50 +0530 Subject: [PATCH 12/95] Verify chatbot admin settings upsert --- admin/src/components/ChatbotAdminControls.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/admin/src/components/ChatbotAdminControls.tsx b/admin/src/components/ChatbotAdminControls.tsx index d403aa83c..c84a4f9f2 100644 --- a/admin/src/components/ChatbotAdminControls.tsx +++ b/admin/src/components/ChatbotAdminControls.tsx @@ -111,10 +111,11 @@ export const ChatbotAdminControls: React.FC = ({ const { data: userData } = await supabase.auth.getUser(); const updatedBy = userData.user?.email || 'admin'; const payload = { ...config, updated_by: updatedBy, updated_at: new Date().toISOString() }; - const { error } = await supabase.from('chatbot_admin_config').update(payload).eq('id', 1); + const { data, error } = await supabase.from('chatbot_admin_config').upsert(payload, { onConflict: 'id' }).select('id, updated_at').single(); if (error) throw error; - setConfig(payload); - setMessage('AI control settings saved to Supabase.'); + if (!data || Number(data.id) !== 1) throw new Error('Settings save verify nahi ho payi.'); + setConfig({ ...payload, updated_at: data.updated_at || payload.updated_at }); + setMessage('AI control settings saved and verified in Supabase.'); } catch (e: any) { setMessage(e?.message || 'Unable to save AI controls.'); } finally { From eee370b0275a25734a20d3dfd841f22939789825 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:36:18 +0530 Subject: [PATCH 13/95] Add chatbot retry action for transient backend errors --- widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widget.js b/widget.js index 3a2922fad..b0873593f 100644 --- a/widget.js +++ b/widget.js @@ -34,7 +34,7 @@ const SHH_WIDGET=(()=>{ }; messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight; } - async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return;if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),18000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[]}),signal:ctl.signal})}finally{clearTimeout(timer)}if(!r.ok)throw Error('Chat request failed ('+r.status+')');const d=await r.json();lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support'}catch(e){addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':'Ji, live AI service me temporary issue aaya. Please try again.',true);document.getElementById('shh-status').textContent='Ready · Please try again'}finally{busy=false}} + async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return;if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),18000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[]}),signal:ctl.signal})}finally{clearTimeout(timer)}const d=await r.json().catch(()=>({}));if(!r.ok){const err=new Error('Chat request failed ('+r.status+')');err.payload=d;err.status=r.status;throw err}lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support'}catch(e){const payload=e?.payload||{};if(payload.error_id)console.error('[StudentHubHelp chatbot error]',payload.error_id);addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':(payload.reply||'Ji, live AI service me temporary issue aaya. Please try again.'),true);if(payload.can_retry||e?.name==='AbortError'||Number(e?.status)>=500){const retryWrap=document.createElement('div');retryWrap.style.cssText='display:flex;justify-content:flex-start;margin:4px 0 8px';const retry=document.createElement('button');retry.type='button';retry.textContent='↻ Retry';retry.style.cssText='border:1px solid #cbd5e1;border-radius:999px;padding:7px 13px;background:#fff;color:#334155;font-size:12px;font-weight:800;cursor:pointer';retry.onclick=()=>{retry.disabled=true;retry.textContent='Retrying…';sendText(text)};retryWrap.appendChild(retry);messages.appendChild(retryWrap);messages.scrollTop=messages.scrollHeight}document.getElementById('shh-status').textContent='Ready · Please try again'}finally{busy=false}} const tts=header.querySelector('#shh-tts');tts.onclick=()=>{speaking=!speaking;tts.textContent=speaking?'🔊':'🔇';if(!speaking&&window.speechSynthesis)window.speechSynthesis.cancel()};header.querySelector('#shh-max').onclick=()=>{expanded=!expanded;root.classList.toggle('shh-expanded',expanded);header.querySelector('#shh-max').textContent=expanded?'↙':'↗'};header.querySelector('#shh-close-btn').onclick=()=>{win.style.display='none';launcher.style.display='flex'};launcher.onclick=()=>{win.style.display='flex';launcher.style.display='none';messages.scrollTop=messages.scrollHeight};input.querySelector('#shh-send-btn').onclick=()=>sendText(); const field=input.querySelector('#shh-input');field.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();sendText()}});input.querySelector('#shh-clear').onclick=()=>{history=[];lastData=null;messages.innerHTML='';document.getElementById('shh-topic').style.display='none';addMessage(getWelcome(),true)}; function getWelcome(){return'Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance AI Assistant** hoon.\n\nAap jo bhi requirement batayenge—Hostel, Tiffin, Library, Cafe, Bookstore, area, budget ya nearby location—main aapki requirement samajhkar live StudentHubHelp listings mein relevant options dhoondunga.\n\n**Aapko kis cheez ki jankari chahiye?**'} From 20de096ed7cee10eac98559aed6e5f6861fd2d69 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:36:37 +0530 Subject: [PATCH 14/95] Reduce chatbot admin config cache staleness --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 53ff0034e..321f71654 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -15,7 +15,7 @@ async function db(path:string):Promise{if(!U||!K)throw Error("database con type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; const SAFE_FALLBACK_CONFIG:ChatbotRuntimeConfig={enabled:false,ai_rerank_enabled:false,lead_capture_enabled:false,comparison_enabled:false,budget_planner_enabled:false,max_candidates:10,max_results:1,max_output_tokens:200,temperature:0,maintenance_message:"Chatbot system maintenance par hai. Kripya thodi der baad koshish karein ya seedhe support par sampark karein."}; let runtimeConfigCache:{value:ChatbotRuntimeConfig;expiresAt:number}|null=null; -async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+15000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);return SAFE_FALLBACK_CONFIG}} +async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+2000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);return SAFE_FALLBACK_CONFIG}} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} function findLandmark(s:string){const x=n(s);for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} From 7e96d582359c35a8ff9145e106c1e27049c45275 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:41:48 +0530 Subject: [PATCH 15/95] Improve chatbot search pushdown, location labels and Gemini fallback --- .../functions/studenthubhelp-chat/index.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 321f71654..d9f3b12b4 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -8,7 +8,7 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const ipKey=cfIp?("ip:"+cfIp.slice(0,100)):"ip:unavailable";const sessionKey=("session:"+String(sessionId||"missing").slice(0,160));return [{scopeKey:ipKey,limit:30,windowSeconds:60},{scopeKey:sessionKey,limit:15,windowSeconds:60}]}; +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60)} async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} @@ -26,12 +26,12 @@ function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude?? function distanceKm(a:any,b:any){const A=coords(a),B=coords(b);if(!A||!B)return undefined;const r=(v:number)=>v*Math.PI/180,dLat=r(B.lat-A.lat),dLon=r(B.lng-A.lng),h=Math.sin(dLat/2)**2+Math.cos(r(A.lat))*Math.cos(r(B.lat))*Math.sin(dLon/2)**2;return 6371*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h))} function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,longitude:l.lng}:null;if(!A||!B)return undefined;return distanceKm(A,B)} function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.city,p.area,p.service_area,p.address,p.landmark,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} -async function direct(type:string,ct?:string){const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return [];const all:any[]=[];let offset=0;const pageSize=500;while(true){const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),offset:String(offset)});if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`);const rows=await db(`/rest/v1/${table}?${params.toString()}`);const page=Array.isArray(rows)?rows:[];all.push(...page.map(p=>({...p,_type:type})));if(page.length=10000)break}return all.filter(p=>!ct||n(p.city||"").includes(n(ct)))} -async function multiDirect(types:string[],ct?:string){const unique=[...new Set(types.filter(Boolean))];const groups=await Promise.all(unique.map(type=>direct(type,ct)));return groups.flat()} +async function direct(type:string,ct?:string,maxBudget?:number){const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return [];const all:any[]=[];let offset=0;const pageSize=500;while(true){const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),offset:String(offset)});if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`);if(maxBudget!==undefined&&Number.isFinite(maxBudget)&&maxBudget>0){const budget=String(Math.floor(maxBudget));const priceFilter=type==="hostel"?`(monthly_rent.lte.${budget},and(monthly_rent.is.null,monthly_fee.lte.${budget}),and(monthly_rent.is.null,monthly_fee.is.null,price.lte.${budget}))`:`(monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget}))`;params.set("or",priceFilter)}const rows=await db(`/rest/v1/${table}?${params.toString()}`);const page=Array.isArray(rows)?rows:[];all.push(...page.map(p=>({...p,_type:type})));if(page.length=10000)break}return all.filter(p=>!ct||n(p.city||"").includes(n(ct)))} +async function multiDirect(types:string[],ct?:string,maxBudget?:number){const unique=[...new Set(types.filter(Boolean))];const groups=await Promise.all(unique.map(type=>direct(type,ct,maxBudget)));return groups.flat()} function genderWanted(s:string){const x=n(s);if(/\b(girls|girl|female|ladki|ladkiyo|ladkiyon|महिला|लड़क|लड़की|girls hostel)\b/i.test(x))return "girls";if(/\b(boys|boy|male|ladke|ladkon|लड़के|लड़का|boys hostel)\b/i.test(x))return "boys";return ""} function areaWanted(s:string){const x=n(s);if(/nawalgarh road|nawalgarh rd|नवलगढ़ रोड/i.test(x))return "nawalgarh";if(/piprali road|piprali rd|पिपराली रोड/i.test(x))return "piprali";return ""} function priceLabel(p:any,c:string){const v=Number(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price);if(!Number.isFinite(v)||v<=0)return "";if(c==="hostel"||c==="tiffin"||c==="library")return "₹"+v.toLocaleString("en-IN")+"/mo";return "₹"+v.toLocaleString("en-IN")} -function card(p:any){const c=p._type,price=priceLabel(p,c),d=p._distanceKm;return{id:String(p.id),name:p.name||p.title||p.service_name||p.business_name||"Active listing",type:c,city:p.city||"",area:p.area||p.service_area||"",address:p.address||"",phone:([p.phone,p.contact_phone,p.whatsapp,p.whatsapp_number].find(v=>{const s=String(v??"").trim();return s&&s!=="0"&&s.replace(/\D/g,"").length>=7})||""),rating:num(p.rating),verified:p.verified===true,price,distanceKm:d===undefined?undefined:Number(d.toFixed(1)),distanceLabel:d===undefined?"":`~${d.toFixed(1)} km`,nearbyMatch:p._nearbyVerified===true||p._nearbyAreaMatch===true,highlights:["wifi","ac_available","ac","cctv","food_available","mess_available","available_beds","room_types","room_sharing","gender_type","timing","timings","delivery_available","nearby_coaching"].map(k=>[k,p[k]]).filter(([,v])=>v!==null&&v!==undefined&&String(v).trim()!==""&&String(v).toLowerCase()!=="false").slice(0,4).map(([k,v])=>k+": "+String(v)),description:p.description&&String(p.description).trim().length<=280?String(p.description).trim():"",mapsUrl:p.google_maps_url||"",detailsUrl:p.slug?`property-details.html?type=${c}&slug=${encodeURIComponent(p.slug)}`:undefined}} +function card(p:any){const c=p._type,price=priceLabel(p,c),d=p._distanceKm;return{id:String(p.id),name:p.name||p.title||p.service_name||p.business_name||"Active listing",type:c,city:p.city||"",area:p.area||p.service_area||"",address:p.address||"",phone:([p.phone,p.contact_phone,p.whatsapp,p.whatsapp_number].find(v=>{const s=String(v??"").trim();return s&&s!=="0"&&s.replace(/\D/g,"").length>=7})||""),rating:num(p.rating),verified:p.verified===true,price,distanceKm:d===undefined?null:Number(d.toFixed(1)),distanceVerified:d!==undefined,distanceLabel:d===undefined?(p._nearbyAreaMatch?`${p.area||p.service_area||p.city||"Area"} area match · exact distance unavailable`:""):`~${d.toFixed(1)} km (GPS straight-line)`,nearbyMatch:p._nearbyVerified===true||p._nearbyAreaMatch===true,highlights:["wifi","ac_available","ac","cctv","food_available","mess_available","available_beds","room_types","room_sharing","gender_type","timing","timings","delivery_available","nearby_coaching"].map(k=>[k,p[k]]).filter(([,v])=>v!==null&&v!==undefined&&String(v).trim()!==""&&String(v).toLowerCase()!=="false").slice(0,4).map(([k,v])=>k+": "+String(v)),description:p.description&&String(p.description).trim().length<=280?String(p.description).trim():"",mapsUrl:p.google_maps_url||"",detailsUrl:p.slug?`property-details.html?type=${c}&slug=${encodeURIComponent(p.slug)}`:undefined}} function rank(p:any,q:string,ct:string,near:string){const x=searchText(p),t=n(q);let s=0;const a=areaWanted(q);if(a&&x.includes(a))s+=25;if(ct&&n(p.city||"").includes(n(ct)))s+=30;for(const w of t.split(" ").filter(z=>z.length>2).slice(0,20))if(x.includes(w))s+=2;if(near){if(near==="allen"||near==="clc"){if(p._distanceKm!==undefined)s+=Math.max(0,20-p._distanceKm*5);if(x.includes(near))s+=10}else if(near==="vibrant"){if(x.includes("vibrant"))s+=20;if(x.includes("nawalgarh"))s+=8}else if(near==="matrix"||near==="gurukripa"){if(x.includes(near))s+=30;else s-=3}else if(x.includes(n(near)))s+=20}if(p.verified===true)s+=4;if(Number.isFinite(Number(p.rating)))s+=Math.min(5,Number(p.rating));return s} function isGreeting(s:string){return /^(hi|hello|hey|namaste|good morning|good afternoon|good evening|good night|gm|gn|hii|hlo|नमस्ते|सुप्रभात|शुभ प्रभात|शुभ संध्या)[!.\s]*$/i.test(s.trim())} function isAccountSupport(s:string){ @@ -65,9 +65,11 @@ function greeting(){return "Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance function label(c:string){return c==="hostel"?"hostel / PG":c==="tiffin"?"tiffin service":c==="library"?"library":c==="cafe"?"cafe":c==="bookstore"?"bookstore":"student service"} function fallbackReply(c:string|undefined,recs:any[],near:string,cityName:string=""){const l=label(c||""),place=near?(near==="allen"?"Allen ke paas":near==="clc"?"CLC ke paas":`${near} area mein`):"";if(recs.length)return `Bilkul 😊 ${place?place+" ":""}${recs.length} active ${l} listing${recs.length>1?"s":""} mili ${recs.length>1?"hain":"hai"}. Neeche live StudentHubHelp options diye hain.`;if(cityName)return `Maine live StudentHubHelp listings check ki hain, lekin abhi **${cityName}** mein koi active matching ${l} listing nahi mili. Main fake options nahi dikhaunga. Aap ${cityName} ka area/landmark bata sakte hain, ya kisi doosri category ki listing search kar sakte hain.`;return `Abhi ${place?place+" ":""}koi matching **${l}** listing nahi mili. Aap city aur area/landmark bataiye; main live listings ko uske hisaab se check karunga.`} function safeJson(s:string){try{return JSON.parse(s)}catch{const m=s.match(/\{[\s\S]*\}/);if(m)try{return JSON.parse(m[0])}catch{}return null}} +const SEARCH_ALIASES:[[RegExp,string]]=[[/\b(?:piparli\s+rd|piprali\s+rd|pipli\s+road|piprali)\b/gi,"piprali road"],[/\b(?:allun|allan|allen\s+institute|allen\s+career)\b/gi,"allen"],[/\b(?:sasta|saste|kam\s+budget)\b/gi,"budget"],[/\b(?:ladko\s+ka|ladkon\s+ka)\b/gi,"boys"],[/\b(?:ladkiyo\s+ka|ladkiyon\s+ka)\b/gi,"girls"]]; +function normalizeSearchTerms(input:string){let value=String(input||"").normalize("NFKC");for(const [pattern,replacement] of SEARCH_ALIASES)value=value.replace(pattern,replacement);return value} function aiIntentDefaults(){return{intent:"general",confidence:0,category:"",categories:[],city:"",locality:"",area:"",landmark:"",nearRelation:"",gender:"",budgetMin:null,budgetMax:null,facilities:[],referenceIndex:null,action:"",followUp:false,correction:false,needsClarification:false,clarificationQuestion:"",searchScope:""}} function offlineIntent(msg:string,history:any[]){ - const o=aiIntentDefaults(),x=n(msg),defs:[string,RegExp][]=[["hostel",/hostel|\bpg\b|room|stay|accommodation|girls hostel|boys hostel|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food service|टिफिन|मेस/],["library",/library|reading room|study room|लाइब्रेरी/],["cafe",/cafe|coffee shop|कैफे/],["bookstore",/bookstore|book shop|bookshop|stationery|बुकस्टोर/]]; + const o=aiIntentDefaults(),x=n(normalizeSearchTerms(msg)),defs:[string,RegExp][]=[["hostel",/hostel|\bpg\b|room|stay|accommodation|girls hostel|boys hostel|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food service|टिफिन|मेस/],["library",/library|reading room|study room|लाइब्रेरी/],["cafe",/cafe|coffee shop|कैफे/],["bookstore",/bookstore|book shop|bookshop|stationery|बुकस्टोर/]]; const cats=defs.filter(([,re])=>re.test(x)).map(([c])=>c); o.categories=[...new Set(cats)];o.category=o.categories[0]||cat(msg)||""; const loc=strictLocation(msg);o.city=loc.city||city(msg)||"";o.locality=loc.locality||"";o.area=loc.area||"";o.landmark=loc.landmark||""; @@ -133,8 +135,7 @@ CURRENT MESSAGE: ${msg} RECENT CONVERSATION: ${h}`; - const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}})}); - if(!r.ok)return fallback;const j=await r.json(),txt=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";return normalizeIntent(safeJson(txt)); + const intentController=new AbortController(),intentTimer=setTimeout(()=>intentController.abort(),2500);try{const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}}),signal:intentController.signal});if(!r.ok)return fallback;const j=await r.json(),txt=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";return normalizeIntent(safeJson(txt))}finally{clearTimeout(intentTimer)} }catch{return fallback} } function mergeIntent(ai:any,msg:string,history:any[]){ @@ -182,7 +183,7 @@ Rules: - Never recommend listings unless the current message has a property-search intent. - Do not expose internal system/tool details. Return only the natural reply text.`; -const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"low"}}})});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}catch{return{}}} +const enhancementController=new AbortController(),enhancementTimer=setTimeout(()=>enhancementController.abort(),8000);try{const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"low"}}}),signal:enhancementController.signal});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}finally{clearTimeout(enhancementTimer)}}catch(e){console.warn("Gemini enhancement skipped; deterministic response retained",e instanceof Error?e.message:String(e));return{}}} async function dbWrite(method:string,path:string,body:any,prefer="return=minimal"){ if(!U||!K)throw Error("database configuration missing"); @@ -382,7 +383,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(isPropertyAdviceQuestion(msg)){const adviceCat=cat(msg)||cat(previous)||"hostel";return await chatResponse({reply:adviceReply(adviceCat),intent:"Property Selection Guidance",primaryTopic:label(adviceCat)+" selection guidance",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a property","Search by area","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(!propertyMode){const ar=await aiGeneral(msg,history,cfg);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} - let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct); + let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined); const wantedGender=u.gender||genderWanted(msg),wantedArea=u.area?(/nawalgarh/i.test(u.area)?"nawalgarh":/piprali/i.test(u.area)?"piprali":u.area.toLowerCase()):areaWanted(msg); const wantedLocality=n(u.locality||""); if(wantedGender)rows=rows.filter(p=>{const z=searchText(p);const g=n([p.gender_type,p.gender,p.hostel_type,p.name,p.title,p.description].filter(Boolean).join(" "));return wantedGender==="girls"?/girls|girl|female|women|ladki|ladkiyon|महिला|लड़क/i.test(g):/boys|boy|male|men|ladke|लड़के/i.test(g)}); @@ -395,7 +396,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(u.budgetMax!==null||u.budgetMin!==null)rows=rows.filter(p=>{const v=budgetValue(p);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); if(u.facilities?.length)rows=rows.filter(p=>matchesFacility(p,u.facilities)); const landmark=u.landmark?findLandmark(u.landmark)||LANDMARKS.find(l=>l.key===u.landmark):findLandmark(near||""); - if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||searchText(p).includes(n(landmark.key))))} + if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d;else if(searchText(p).includes(n(landmark.key)))p._nearbyAreaMatch=true}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||p._nearbyAreaMatch===true))} else if(near){const q=n(near);const exact=rows.filter(p=>searchText(p).includes(q));if(exact.length)rows=exact;else if(!wantedArea)rows=[]} if(nearCats.length){const secondaryRows=await multiDirect(nearCats,ct);for(const p of rows){let best=Infinity,hasCoords=false;for(const q of secondaryRows){const d=distanceKm(p,q);if(d!==undefined){hasCoords=true;best=Math.min(best,d)}else if(p.area&&searchText(q).includes(n(p.area)))best=Math.min(best,1)}if(best<=3){p._nearbyDistanceKm=best;p._nearbyVerified=hasCoords;p._nearbyAreaMatch=!hasCoords}}rows=rows.filter(p=>p._nearbyDistanceKm!==undefined)} rows=rows.filter(p=>!shownIds.has(String(p.id))); From 9acec1ca1f6c463e3dcbf890723404de073a1879 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:41:58 +0530 Subject: [PATCH 16/95] Align embedded chatbot request timeout to 15 seconds --- widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widget.js b/widget.js index b0873593f..050e20dad 100644 --- a/widget.js +++ b/widget.js @@ -34,7 +34,7 @@ const SHH_WIDGET=(()=>{ }; messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight; } - async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return;if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),18000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[]}),signal:ctl.signal})}finally{clearTimeout(timer)}const d=await r.json().catch(()=>({}));if(!r.ok){const err=new Error('Chat request failed ('+r.status+')');err.payload=d;err.status=r.status;throw err}lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support'}catch(e){const payload=e?.payload||{};if(payload.error_id)console.error('[StudentHubHelp chatbot error]',payload.error_id);addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':(payload.reply||'Ji, live AI service me temporary issue aaya. Please try again.'),true);if(payload.can_retry||e?.name==='AbortError'||Number(e?.status)>=500){const retryWrap=document.createElement('div');retryWrap.style.cssText='display:flex;justify-content:flex-start;margin:4px 0 8px';const retry=document.createElement('button');retry.type='button';retry.textContent='↻ Retry';retry.style.cssText='border:1px solid #cbd5e1;border-radius:999px;padding:7px 13px;background:#fff;color:#334155;font-size:12px;font-weight:800;cursor:pointer';retry.onclick=()=>{retry.disabled=true;retry.textContent='Retrying…';sendText(text)};retryWrap.appendChild(retry);messages.appendChild(retryWrap);messages.scrollTop=messages.scrollHeight}document.getElementById('shh-status').textContent='Ready · Please try again'}finally{busy=false}} + async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return;if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),15000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[]}),signal:ctl.signal})}finally{clearTimeout(timer)}const d=await r.json().catch(()=>({}));if(!r.ok){const err=new Error('Chat request failed ('+r.status+')');err.payload=d;err.status=r.status;throw err}lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support'}catch(e){const payload=e?.payload||{};if(payload.error_id)console.error('[StudentHubHelp chatbot error]',payload.error_id);addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':(payload.reply||'Ji, live AI service me temporary issue aaya. Please try again.'),true);if(payload.can_retry||e?.name==='AbortError'||Number(e?.status)>=500){const retryWrap=document.createElement('div');retryWrap.style.cssText='display:flex;justify-content:flex-start;margin:4px 0 8px';const retry=document.createElement('button');retry.type='button';retry.textContent='↻ Retry';retry.style.cssText='border:1px solid #cbd5e1;border-radius:999px;padding:7px 13px;background:#fff;color:#334155;font-size:12px;font-weight:800;cursor:pointer';retry.onclick=()=>{retry.disabled=true;retry.textContent='Retrying…';sendText(text)};retryWrap.appendChild(retry);messages.appendChild(retryWrap);messages.scrollTop=messages.scrollHeight}document.getElementById('shh-status').textContent='Ready · Please try again'}finally{busy=false}} const tts=header.querySelector('#shh-tts');tts.onclick=()=>{speaking=!speaking;tts.textContent=speaking?'🔊':'🔇';if(!speaking&&window.speechSynthesis)window.speechSynthesis.cancel()};header.querySelector('#shh-max').onclick=()=>{expanded=!expanded;root.classList.toggle('shh-expanded',expanded);header.querySelector('#shh-max').textContent=expanded?'↙':'↗'};header.querySelector('#shh-close-btn').onclick=()=>{win.style.display='none';launcher.style.display='flex'};launcher.onclick=()=>{win.style.display='flex';launcher.style.display='none';messages.scrollTop=messages.scrollHeight};input.querySelector('#shh-send-btn').onclick=()=>sendText(); const field=input.querySelector('#shh-input');field.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();sendText()}});input.querySelector('#shh-clear').onclick=()=>{history=[];lastData=null;messages.innerHTML='';document.getElementById('shh-topic').style.display='none';addMessage(getWelcome(),true)}; function getWelcome(){return'Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance AI Assistant** hoon.\n\nAap jo bhi requirement batayenge—Hostel, Tiffin, Library, Cafe, Bookstore, area, budget ya nearby location—main aapki requirement samajhkar live StudentHubHelp listings mein relevant options dhoondunga.\n\n**Aapko kis cheez ki jankari chahiye?**'} From 2a00fa8f91f13b5949f022422dc1657ad3532dd3 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:42:30 +0530 Subject: [PATCH 17/95] Correct rate key return and normalize location aliases --- supabase/functions/studenthubhelp-chat/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index d9f3b12b4..72c261604 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -8,7 +8,7 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60)} +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60)};return keys} async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} @@ -18,8 +18,8 @@ let runtimeConfigCache:{value:ChatbotRuntimeConfig;expiresAt:number}|null=null; async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+2000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);return SAFE_FALLBACK_CONFIG}} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} -function findLandmark(s:string){const x=n(s);for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} -function nearTerm(s:string){const x=n(s),lm=findLandmark(x);if(lm)return lm.key;const known=["vibrant coaching","vibrant career institute","nawalgarh road","nawalgarh rd","nawalgarh","clc","allen","gurukripa","matrix","matrix academy","prayas eduhub","banco career academy","inspector ssc academy"];const hit=known.find(k=>x.includes(n(k)));if(hit)return hit;const m=x.match(/(?:near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)(?:\s+me)?\s+([a-z0-9\u0900-\u097f][a-z0-9\u0900-\u097f ]{1,50}?)(?=\s+(?:chahiye|dikhao|dikhado|hai|ho|rehna|rahna|book|konsa|kaunsa|aur|bhi)\b|$)/i);if(m)return m[1].trim();if(/(?:piprali road|piprali rd|पिपराली रोड)/i.test(x))return "piprali road";return ""} +function findLandmark(s:string){const x=n(normalizeSearchTerms(s));for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} +function nearTerm(s:string){const x=n(normalizeSearchTerms(s)),lm=findLandmark(x);if(lm)return lm.key;const known=["vibrant coaching","vibrant career institute","nawalgarh road","nawalgarh rd","nawalgarh","clc","allen","gurukripa","matrix","matrix academy","prayas eduhub","banco career academy","inspector ssc academy"];const hit=known.find(k=>x.includes(n(k)));if(hit)return hit;const m=x.match(/(?:near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)(?:\s+me)?\s+([a-z0-9\u0900-\u097f][a-z0-9\u0900-\u097f ]{1,50}?)(?=\s+(?:chahiye|dikhao|dikhado|hai|ho|rehna|rahna|book|konsa|kaunsa|aur|bhi)\b|$)/i);if(m)return m[1].trim();if(/(?:piprali road|piprali rd|पिपराली रोड)/i.test(x))return "piprali road";return ""} function cat(s:string){const x=n(s),hits:[string,number][]=[];const defs:[string,RegExp][]=[["hostel",/hostel|pg|room|stay|accommodation|girls|boys|female|male|rehne|rehna|रहने|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food|meal|jain food|टिफिन|मेस|खाना/],["library",/library|reading|seat|लाइब्रेरी/],["cafe",/cafe|coffee|restaurant|hangout|कैफे/],["bookstore",/bookstore|books|stationery|bookshop|किताब|बुकस्टोर/]];for(const [type,re] of defs)for(const m of x.matchAll(new RegExp(re.source,"gi")))hits.push([type,m.index??-1]);const near=new Set(hits.filter(([,pos])=>/(near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)/i.test(x.slice(Math.max(0,pos-50),pos))).map(([type])=>type));return hits.find(([type])=>!near.has(type))?.[0]||hits[0]?.[0]} function secondaryCats(s:string){const x=n(s),out:string[]=[];const defs:[string,RegExp][]=[["hostel",/hostel|pg|room|stay|accommodation|girls|boys|female|male|rehne|rehna|रहने|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food|meal|टिफिन|मेस|खाना/],["library",/library|reading|seat|लाइब्रेरी/],["cafe",/cafe|coffee|restaurant|hangout|कैफे/],["bookstore",/bookstore|books|stationery|bookshop|किताब|बुकस्टोर/]];for(const [type,re] of defs)for(const m of x.matchAll(new RegExp(re.source,"gi"))){const pos=m.index??-1;if(/(near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)/i.test(x.slice(Math.max(0,pos-50),pos)))out.push(type)}return [...new Set(out)]} function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude??p.latitude_deg),lng=Number(p.longitude??p.lng??p.lon??p.location_longitude??p.longitude_deg);return Number.isFinite(lat)&&Number.isFinite(lng)?{lat,lng}:null} @@ -28,8 +28,8 @@ function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,l function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.city,p.area,p.service_area,p.address,p.landmark,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} async function direct(type:string,ct?:string,maxBudget?:number){const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return [];const all:any[]=[];let offset=0;const pageSize=500;while(true){const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),offset:String(offset)});if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`);if(maxBudget!==undefined&&Number.isFinite(maxBudget)&&maxBudget>0){const budget=String(Math.floor(maxBudget));const priceFilter=type==="hostel"?`(monthly_rent.lte.${budget},and(monthly_rent.is.null,monthly_fee.lte.${budget}),and(monthly_rent.is.null,monthly_fee.is.null,price.lte.${budget}))`:`(monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget}))`;params.set("or",priceFilter)}const rows=await db(`/rest/v1/${table}?${params.toString()}`);const page=Array.isArray(rows)?rows:[];all.push(...page.map(p=>({...p,_type:type})));if(page.length=10000)break}return all.filter(p=>!ct||n(p.city||"").includes(n(ct)))} async function multiDirect(types:string[],ct?:string,maxBudget?:number){const unique=[...new Set(types.filter(Boolean))];const groups=await Promise.all(unique.map(type=>direct(type,ct,maxBudget)));return groups.flat()} -function genderWanted(s:string){const x=n(s);if(/\b(girls|girl|female|ladki|ladkiyo|ladkiyon|महिला|लड़क|लड़की|girls hostel)\b/i.test(x))return "girls";if(/\b(boys|boy|male|ladke|ladkon|लड़के|लड़का|boys hostel)\b/i.test(x))return "boys";return ""} -function areaWanted(s:string){const x=n(s);if(/nawalgarh road|nawalgarh rd|नवलगढ़ रोड/i.test(x))return "nawalgarh";if(/piprali road|piprali rd|पिपराली रोड/i.test(x))return "piprali";return ""} +function genderWanted(s:string){const x=n(normalizeSearchTerms(s));if(/\b(girls|girl|female|ladki|ladkiyo|ladkiyon|महिला|लड़क|लड़की|girls hostel)\b/i.test(x))return "girls";if(/\b(boys|boy|male|ladke|ladkon|लड़के|लड़का|boys hostel)\b/i.test(x))return "boys";return ""} +function areaWanted(s:string){const x=n(normalizeSearchTerms(s));if(/nawalgarh road|nawalgarh rd|नवलगढ़ रोड/i.test(x))return "nawalgarh";if(/piprali road|piprali rd|पिपराली रोड/i.test(x))return "piprali";return ""} function priceLabel(p:any,c:string){const v=Number(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price);if(!Number.isFinite(v)||v<=0)return "";if(c==="hostel"||c==="tiffin"||c==="library")return "₹"+v.toLocaleString("en-IN")+"/mo";return "₹"+v.toLocaleString("en-IN")} function card(p:any){const c=p._type,price=priceLabel(p,c),d=p._distanceKm;return{id:String(p.id),name:p.name||p.title||p.service_name||p.business_name||"Active listing",type:c,city:p.city||"",area:p.area||p.service_area||"",address:p.address||"",phone:([p.phone,p.contact_phone,p.whatsapp,p.whatsapp_number].find(v=>{const s=String(v??"").trim();return s&&s!=="0"&&s.replace(/\D/g,"").length>=7})||""),rating:num(p.rating),verified:p.verified===true,price,distanceKm:d===undefined?null:Number(d.toFixed(1)),distanceVerified:d!==undefined,distanceLabel:d===undefined?(p._nearbyAreaMatch?`${p.area||p.service_area||p.city||"Area"} area match · exact distance unavailable`:""):`~${d.toFixed(1)} km (GPS straight-line)`,nearbyMatch:p._nearbyVerified===true||p._nearbyAreaMatch===true,highlights:["wifi","ac_available","ac","cctv","food_available","mess_available","available_beds","room_types","room_sharing","gender_type","timing","timings","delivery_available","nearby_coaching"].map(k=>[k,p[k]]).filter(([,v])=>v!==null&&v!==undefined&&String(v).trim()!==""&&String(v).toLowerCase()!=="false").slice(0,4).map(([k,v])=>k+": "+String(v)),description:p.description&&String(p.description).trim().length<=280?String(p.description).trim():"",mapsUrl:p.google_maps_url||"",detailsUrl:p.slug?`property-details.html?type=${c}&slug=${encodeURIComponent(p.slug)}`:undefined}} function rank(p:any,q:string,ct:string,near:string){const x=searchText(p),t=n(q);let s=0;const a=areaWanted(q);if(a&&x.includes(a))s+=25;if(ct&&n(p.city||"").includes(n(ct)))s+=30;for(const w of t.split(" ").filter(z=>z.length>2).slice(0,20))if(x.includes(w))s+=2;if(near){if(near==="allen"||near==="clc"){if(p._distanceKm!==undefined)s+=Math.max(0,20-p._distanceKm*5);if(x.includes(near))s+=10}else if(near==="vibrant"){if(x.includes("vibrant"))s+=20;if(x.includes("nawalgarh"))s+=8}else if(near==="matrix"||near==="gurukripa"){if(x.includes(near))s+=30;else s-=3}else if(x.includes(n(near)))s+=20}if(p.verified===true)s+=4;if(Number.isFinite(Number(p.rating)))s+=Math.min(5,Number(p.rating));return s} @@ -109,9 +109,9 @@ function deterministicLocation(s:string){ const lm=findLandmark(x);if(lm)return{city:lm.city,landmark:lm.key};return{}; } function strictLocation(s:string){ - const d=deterministicLocation(s),x=n(s); + const normalized=normalizeSearchTerms(s),d=deterministicLocation(normalized),x=n(normalized); if(d.city||d.locality||d.area||d.landmark)return d; - const c=city(s);return c?{city:c}:{}; + const c=city(normalized);return c?{city:c}:{}; } async function aiUnderstand(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG){ const fallback=offlineIntent(msg,history);if(!GEMINI)return fallback; From 54827588d6391be12ff006fc353344785ec0871c Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:43:12 +0530 Subject: [PATCH 18/95] Bound Gemini calls to shared 12-second request deadline --- .../functions/studenthubhelp-chat/index.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 72c261604..e944c4da0 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -8,7 +8,7 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60)};return keys} +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60});return keys};return keys} async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} @@ -113,7 +113,7 @@ function strictLocation(s:string){ if(d.city||d.locality||d.area||d.landmark)return d; const c=city(normalized);return c?{city:c}:{}; } -async function aiUnderstand(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG){ +async function aiUnderstand(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){ const fallback=offlineIntent(msg,history);if(!GEMINI)return fallback; try{ const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"").slice(0,1200)}`).join("\n"); @@ -135,7 +135,7 @@ CURRENT MESSAGE: ${msg} RECENT CONVERSATION: ${h}`; - const intentController=new AbortController(),intentTimer=setTimeout(()=>intentController.abort(),2500);try{const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}}),signal:intentController.signal});if(!r.ok)return fallback;const j=await r.json(),txt=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";return normalizeIntent(safeJson(txt))}finally{clearTimeout(intentTimer)} + const intentTimeout=Math.min(2500,Math.max(0,deadlineAt===undefined?2500:deadlineAt-Date.now()));if(intentTimeout<100)return fallback;const intentController=new AbortController(),intentTimer=setTimeout(()=>intentController.abort(),intentTimeout);try{const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{responseMimeType:"application/json",maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}}),signal:intentController.signal});if(!r.ok)return fallback;const j=await r.json(),txt=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";return normalizeIntent(safeJson(txt))}finally{clearTimeout(intentTimer)} }catch{return fallback} } function mergeIntent(ai:any,msg:string,history:any[]){ @@ -160,7 +160,7 @@ function mergeIntent(ai:any,msg:string,history:any[]){ function budgetValue(p:any){return num(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price)} function matchesFacility(p:any,wanted:string[]){if(!wanted.length)return true;const z=searchText(p);return wanted.every(f=>z.includes(n(f))||Object.entries(p).some(([k,v])=>n(k).includes(n(f))&&(v===true||String(v).toLowerCase()==="true"||n(v).includes(n(f)))))} -async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string,cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG){if(!GEMINI||!cfg.ai_rerank_enabled)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. +async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string,cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){if(!GEMINI||!cfg.ai_rerank_enabled)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. Match the user's language: natural Hinglish/Hindi/English. Be warm, concise, clear and impressive. Current user: ${msg} @@ -183,7 +183,7 @@ Rules: - Never recommend listings unless the current message has a property-search intent. - Do not expose internal system/tool details. Return only the natural reply text.`; -const enhancementController=new AbortController(),enhancementTimer=setTimeout(()=>enhancementController.abort(),8000);try{const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"low"}}}),signal:enhancementController.signal});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}finally{clearTimeout(enhancementTimer)}}catch(e){console.warn("Gemini enhancement skipped; deterministic response retained",e instanceof Error?e.message:String(e));return{}}} +const enhancementTimeout=Math.min(8000,Math.max(0,deadlineAt===undefined?8000:deadlineAt-Date.now()));if(enhancementTimeout<250)return{};const enhancementController=new AbortController(),enhancementTimer=setTimeout(()=>enhancementController.abort(),enhancementTimeout);try{const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify({contents:[{role:"user",parts:[{text:prompt}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"low"}}}),signal:enhancementController.signal});if(!r.ok)return{};const j=await r.json();return{reply:j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||""}}finally{clearTimeout(enhancementTimer)}}catch(e){console.warn("Gemini enhancement skipped; deterministic response retained",e instanceof Error?e.message:String(e));return{}}} async function dbWrite(method:string,path:string,body:any,prefer="return=minimal"){ if(!U||!K)throw Error("database configuration missing"); @@ -234,7 +234,7 @@ function offlineGeneralReply(msg:string){ if(/hello|hi|hey|namaste|kaise ho|kya haal/i.test(x))return "Namaste 😊 Main yahin hoon—study questions, general knowledge, ideas ya StudentHubHelp listings mein help kar sakta hoon. Aaj kya karna hai?"; return "Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊"; } -async function aiGeneral(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG){const offline=offlineGeneralReply(msg);if(!GEMINI||offline!=="Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊")return{reply:offline,webGrounded:false,webSources:[]};try{const h=history.slice(-8).map(x=>({role:x.role==="assistant"?"model":"user",parts:[{text:String(x.text||"").slice(0,1500)}]}));const system=`You are StudentHubHelp's Ultra Advance AI Assistant for students. +async function aiGeneral(msg:string,history:any[],cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){const offline=offlineGeneralReply(msg);if(!GEMINI||offline!=="Main bina external AI API key ke live StudentHubHelp listings, location-based search, booking guidance aur kuch common study/general questions handle kar sakta hoon. Is sawaal ka reliable jawab dene ke liye thoda aur context ya specific question bhej dijiye 😊")return{reply:offline,webGrounded:false,webSources:[]};try{const h=history.slice(-8).map(x=>({role:x.role==="assistant"?"model":"user",parts:[{text:String(x.text||"").slice(0,1500)}]}));const system=`You are StudentHubHelp's Ultra Advance AI Assistant for students. You are a natural conversational, study and general knowledge assistant. Match the user's language and tone: Hindi, Hinglish or English. You can discuss normal life, boredom, tiredness, motivation, hobbies and casual topics in a friendly respectful way. @@ -244,9 +244,10 @@ Do not claim StudentHubHelp property facts here. Property data belongs to the se Do not pretend to know personal facts about the user. Keep normal chat concise and human; for study questions be clear and useful. Current message: ${msg}`; -const body:any={contents:[...h,{role:"user",parts:[{text:system}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}};if(wantsCurrentWeb(msg))body.tools=[{google_search:{}}];const r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify(body)});if(!r.ok)return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]};const j=await r.json();const reply=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";const chunks=j?.candidates?.[0]?.groundingMetadata?.groundingChunks||[];const sources=chunks.map((x:any)=>x?.web?.uri&&{url:x.web.uri,title:x.web.title||x.web.uri}).filter(Boolean).slice(0,5);return{reply:reply||offlineGeneralReply(msg),webGrounded:sources.length>0,webSources:sources}}catch{return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]}}} +const body:any={contents:[...h,{role:"user",parts:[{text:system}]}],generationConfig:{maxOutputTokens:cfg.max_output_tokens,temperature:cfg.temperature,thinkingConfig:{thinkingLevel:"medium"}}};if(wantsCurrentWeb(msg))body.tools=[{google_search:{}}];const aiTimeout=Math.min(8000,Math.max(0,deadlineAt===undefined?8000:deadlineAt-Date.now()));if(aiTimeout<250)return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]};const aiController=new AbortController(),aiTimer=setTimeout(()=>aiController.abort(),aiTimeout);let r:Response;try{r=await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,{method:"POST",headers:{"Content-Type":"application/json","x-goog-api-key":GEMINI},body:JSON.stringify(body),signal:aiController.signal})}finally{clearTimeout(aiTimer)};if(!r.ok)return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]};const j=await r.json();const reply=j?.candidates?.[0]?.content?.parts?.map((p:any)=>p.text||"").join("")||"";const chunks=j?.candidates?.[0]?.groundingMetadata?.groundingChunks||[];const sources=chunks.map((x:any)=>x?.web?.uri&&{url:x.web.uri,title:x.web.title||x.web.uri}).filter(Boolean).slice(0,5);return{reply:reply||offlineGeneralReply(msg),webGrounded:sources.length>0,webSources:sources}}catch{return{reply:offlineGeneralReply(msg),webGrounded:false,webSources:[]}}} Deno.serve(async(req:Request)=>{ + const requestDeadlineAt=Date.now()+12000; if(req.method==="OPTIONS")return new Response("ok",{headers:H}); if(req.method!=="POST")return new Response(JSON.stringify({error:"POST required"}),{status:405,headers:H}); try{ @@ -297,14 +298,14 @@ Deno.serve(async(req:Request)=>{ const reply=validPhone?"Ji 😊 **"+fp.name+"** ka listed phone number: **"+validPhone+"**":"Is listing ka valid phone number abhi StudentHubHelp data me available nahi hai."; return await chatResponse({reply,intent:"Property Contact",primaryTopic:label(focusType)+" contact",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iske full details batao","Allen ke paas aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } - const ar=await aiProperty(msg,history,[fp],focusType,nearTerm(focusCombined),cfg); + const ar=await aiProperty(msg,history,[fp],focusType,nearTerm(focusCombined),cfg,requestDeadlineAt); const reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain."); return await chatResponse({reply,intent:"Property Details",primaryTopic:label(focusType)+" details",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Allen ke paas aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } } if(isAccountSupport(msg))return await chatResponse({reply:accountSupportReply(msg),intent:"Account / Login Support",primaryTopic:"Account access",suggestedFollowUps:["I forgot my password","Google login is not working","Dashboard is not opening","Contact support"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); if(isContact(msg)||String(b?.action||"")==="contact_support")return await chatResponse({reply:"Ji bilkul 😊 StudentHubHelp team se contact ke liye:\n\n📧 Email: studenthubhelpp@gmail.com\n📞 Phone: +91 9929718264",intent:"Support / Contact",primaryTopic:"Contact StudentHubHelp",suggestedFollowUps:["Find a hostel","Find a library","Search by area","Back to search"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); - const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),aiCats=u.categories||[],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); + const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),aiCats=u.categories||[],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); if(action==="search_area"&&!city(msg)&&!nearTerm(msg))return await chatResponse({reply:"Bilkul 😊 Aap kis **area / locality** mein search karna chahte hain? Area ka naam bhejiye, main current requirement ke saath search refine kar dunga.",intent:"Area Refinement",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by city","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.needsClarification&&u.clarificationQuestion&&!u.category&&!u.categories.length&&!u.locality&&!u.area&&!u.landmark)return await chatResponse({reply:u.clarificationQuestion,intent:"Clarification",primaryTopic:"Requirement clarification",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a hostel","Find a library","Find a cafe","Find a tiffin service"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.intent==="property_reference"&&u.referenceIndex){ @@ -314,7 +315,7 @@ Deno.serve(async(req:Request)=>{ if(target){ const fp=card(target),wantPhone=u.action==="phone"||isPhoneIntent(msg); if(wantPhone){const ph=String(fp.phone||"").trim();const reply=ph&&ph.replace(/\\D/g,"").length>=7?"Ji 😊 **"+fp.name+"** ka listed phone number: **"+ph+"**":"Is listing ka valid phone number abhi StudentHubHelp data me available nahi hai.";return await chatResponse({reply,intent:"Property Contact",primaryTopic:"Referenced property contact",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iske full details batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg)} - const ar=await aiProperty(msg,history,[fp],fp._type,near,cfg),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) + const ar=await aiProperty(msg,history,[fp],fp._type,near,cfg,requestDeadlineAt),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) } } if(cfg.comparison_enabled&&(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg)))&&!isBookingInfoQuestion(msg)){ @@ -324,7 +325,7 @@ Deno.serve(async(req:Request)=>{ let decision=decisionRows.filter(p=>!shownIds.has(String(p.id))); if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||searchText(p).includes(n(decisionLandmark.key)))); const rankedDecision=decision.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_results).map(card); - const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near,cfg); + const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near,cfg,requestDeadlineAt); const reply=ar.reply||"Aapki requirement ke hisaab se live options compare kar sakte hain."; return await chatResponse({reply,intent:"Property Comparison",primaryTopic:label(c||"hostel")+" comparison",recommendedProperties:rankedDecision,recommendations:rankedDecision,suggestedFollowUps:["Boys hostel dikhao","Girls hostel dikhao","Budget ke according dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg); } @@ -381,8 +382,8 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det } if(isPropertyAdviceQuestion(msg)){const adviceCat=cat(msg)||cat(previous)||"hostel";return await chatResponse({reply:adviceReply(adviceCat),intent:"Property Selection Guidance",primaryTopic:label(adviceCat)+" selection guidance",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a property","Search by area","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg);} - if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} - if(!propertyMode){const ar=await aiGeneral(msg,history,cfg);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} + if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg,requestDeadlineAt);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} + if(!propertyMode){const ar=await aiGeneral(msg,history,cfg,requestDeadlineAt);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined); const wantedGender=u.gender||genderWanted(msg),wantedArea=u.area?(/nawalgarh/i.test(u.area)?"nawalgarh":/piprali/i.test(u.area)?"piprali":u.area.toLowerCase()):areaWanted(msg); const wantedLocality=n(u.locality||""); @@ -403,7 +404,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det const ranked=rows.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_candidates); let recs=ranked.slice(0,cfg.max_results).map(card); if(action==="view_details"){const focusId=String(b?.focusPropertyId||""),focusType=String(b?.focusPropertyType||c||"hostel"),focusRows=await multiDirect([focusType],ct),focus=focusRows.find(p=>String(p.id)===focusId);recs=focus?[card(focus)]:recs.slice(0,1)} - const ar=await aiProperty(msg,history,recs,c,near,cfg),reply=ar.reply||fallbackReply(c,recs,near,ct),followups=["View full details","Search by area","Show more options","Contact support"]; + const ar=await aiProperty(msg,history,recs,c,near,cfg,requestDeadlineAt),reply=ar.reply||fallbackReply(c,recs,near,ct),followups=["View full details","Search by area","Show more options","Contact support"]; return await chatResponse({reply,intent:"Live Property Search",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:recs,recommendations:recs,suggestedFollowUps:followups,grounded:true,sessionId,searchMode:"ultra_advance_live_supabase_ai",targetCity:ct||undefined,targetCategory:c||undefined,nearbyTerm:near||undefined,aiIntent:u.intent,aiConfidence:u.confidence,aiEntities:{category:u.category,categories:u.categories,city:u.city,locality:u.locality,area:u.area,landmark:u.landmark,gender:u.gender,budgetMin:u.budgetMin,budgetMax:u.budgetMax,facilities:u.facilities,referenceIndex:u.referenceIndex}},{headers:H},sessionId,msg); }catch(e){const errorId=crypto.randomUUID();console.error(`[ERROR_REF:${errorId}] Internal chatbot failure`,{message:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:undefined,timestamp:new Date().toISOString()});return new Response(JSON.stringify({success:false,reply:"Server par temporary connectivity issue aaya hai. Kripya 1 minute baad punah prayas karein.",error:"Temporary server error",error_id:errorId,can_retry:true,grounded:false}),{status:500,headers:{...H,"Retry-After":"60"}})} }); \ No newline at end of file From 4fef81b714f7fc47583cab298989e4a4d445a973 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:43:31 +0530 Subject: [PATCH 19/95] Fix rate key function and search alias typing --- supabase/functions/studenthubhelp-chat/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index e944c4da0..6578a6656 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -8,7 +8,7 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60});return keys};return keys} +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60});return keys} async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} @@ -65,7 +65,7 @@ function greeting(){return "Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance function label(c:string){return c==="hostel"?"hostel / PG":c==="tiffin"?"tiffin service":c==="library"?"library":c==="cafe"?"cafe":c==="bookstore"?"bookstore":"student service"} function fallbackReply(c:string|undefined,recs:any[],near:string,cityName:string=""){const l=label(c||""),place=near?(near==="allen"?"Allen ke paas":near==="clc"?"CLC ke paas":`${near} area mein`):"";if(recs.length)return `Bilkul 😊 ${place?place+" ":""}${recs.length} active ${l} listing${recs.length>1?"s":""} mili ${recs.length>1?"hain":"hai"}. Neeche live StudentHubHelp options diye hain.`;if(cityName)return `Maine live StudentHubHelp listings check ki hain, lekin abhi **${cityName}** mein koi active matching ${l} listing nahi mili. Main fake options nahi dikhaunga. Aap ${cityName} ka area/landmark bata sakte hain, ya kisi doosri category ki listing search kar sakte hain.`;return `Abhi ${place?place+" ":""}koi matching **${l}** listing nahi mili. Aap city aur area/landmark bataiye; main live listings ko uske hisaab se check karunga.`} function safeJson(s:string){try{return JSON.parse(s)}catch{const m=s.match(/\{[\s\S]*\}/);if(m)try{return JSON.parse(m[0])}catch{}return null}} -const SEARCH_ALIASES:[[RegExp,string]]=[[/\b(?:piparli\s+rd|piprali\s+rd|pipli\s+road|piprali)\b/gi,"piprali road"],[/\b(?:allun|allan|allen\s+institute|allen\s+career)\b/gi,"allen"],[/\b(?:sasta|saste|kam\s+budget)\b/gi,"budget"],[/\b(?:ladko\s+ka|ladkon\s+ka)\b/gi,"boys"],[/\b(?:ladkiyo\s+ka|ladkiyon\s+ka)\b/gi,"girls"]]; +const SEARCH_ALIASES:[RegExp,string][]=[[/\b(?:piparli\s+rd|piprali\s+rd|pipli\s+road|piprali)\b/gi,"piprali road"],[/\b(?:allun|allan|allen\s+institute|allen\s+career)\b/gi,"allen"],[/\b(?:sasta|saste|kam\s+budget)\b/gi,"budget"],[/\b(?:ladko\s+ka|ladkon\s+ka)\b/gi,"boys"],[/\b(?:ladkiyo\s+ka|ladkiyon\s+ka)\b/gi,"girls"]]; function normalizeSearchTerms(input:string){let value=String(input||"").normalize("NFKC");for(const [pattern,replacement] of SEARCH_ALIASES)value=value.replace(pattern,replacement);return value} function aiIntentDefaults(){return{intent:"general",confidence:0,category:"",categories:[],city:"",locality:"",area:"",landmark:"",nearRelation:"",gender:"",budgetMin:null,budgetMax:null,facilities:[],referenceIndex:null,action:"",followUp:false,correction:false,needsClarification:false,clarificationQuestion:"",searchScope:""}} function offlineIntent(msg:string,history:any[]){ From 6f97cded4d3caf26632eb67b549a4f640d768b78 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:44:23 +0530 Subject: [PATCH 20/95] Fail closed when trusted client IP header is unavailable --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 6578a6656..5dafff7cc 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -9,7 +9,7 @@ const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["c const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60});return keys} -async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} +async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){if(!req.headers.get("cf-connecting-ip")?.trim())throw Error("trusted Cloudflare client IP header unavailable");for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; From 46ee0c2da796d28518bbaec54f9bfef7e9e83dca Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:44:27 +0530 Subject: [PATCH 21/95] Display verified GPS or approximate area match in chatbot cards --- widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widget.js b/widget.js index 050e20dad..ede0ac7b9 100644 --- a/widget.js +++ b/widget.js @@ -20,7 +20,7 @@ const SHH_WIDGET=(()=>{ const strip=t=>String(t??'').replace(/\s{2,}/g,' ').trim();function formatText(t){let x=esc(strip(t));x=x.replace(/\*\*(.+?)\*\*/g,'$1');return x.replace(/\n/g,'
')} function speak(text){if(!speaking||!window.speechSynthesis)return;window.speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(String(text).replace(/[*_#]/g,' ').replace(/\n+/g,'. '));u.lang='hi-IN';const vs=window.speechSynthesis.getVoices();const v=vs.find(x=>/^hi(-|_)/i.test(x.lang))||vs.find(x=>/^en(-|_)/i.test(x.lang));if(v)u.voice=v;window.speechSynthesis.speak(u)} function addMessage(text,isBot,data){const wrap=document.createElement('div');wrap.style.cssText='align-self:'+(isBot?'stretch':'flex-end')+';max-width:'+(isBot?'100%':'88%')+';width:'+(isBot?'100%':'auto')+';';const bubble=document.createElement('div');bubble.style.cssText='background:'+(isBot?'#fff':'#071a33')+';color:'+(isBot?'#172033':'#fff')+';border:'+(isBot?'1px solid #e1e8f0':'0')+';border-radius:18px;padding:11px 13px;font-size:13px;line-height:1.58;box-shadow:0 3px 12px rgba(15,23,42,.06);word-break:break-word;';if(isBot){const meta=document.createElement('div');meta.style.cssText='display:flex;align-items:center;gap:6px;font-size:9px;font-weight:800;color:#64748b;margin-bottom:5px';meta.innerHTML='● StudentHubHelp AI';bubble.appendChild(meta)}const body=document.createElement('div');body.innerHTML=formatText(text);bubble.appendChild(body);wrap.appendChild(bubble);if(isBot&&data)renderData(wrap,data);messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight;if(isBot)speak(strip(text))} - function renderPropertyCard(p){const card=document.createElement('div');card.className='shh-card';const loc=[p.area,p.city].filter(Boolean).join(', '),phone=p.phone||p.whatsapp,maps=safeUrl(p.mapsUrl),details=safeUrl(p.detailsUrl);const top=document.createElement('div');top.style.cssText='display:flex;justify-content:space-between;gap:8px;align-items:flex-start';top.innerHTML='
'+icon(p.type)+' '+esc(p.name||'Active listing')+'
'+(loc?'
📍 '+esc(loc)+'
':'')+'
'+(p.verified?'✓ Verified Active':'Active')+'';card.appendChild(top);if(p.price){const z=document.createElement('div');z.className='shh-card-price';z.textContent=p.price;card.appendChild(z)}if(p.rating!==undefined&&p.rating!==null){const z=document.createElement('div');z.style.cssText='margin-top:5px;font-size:10px;font-weight:900;color:#8a6415';z.textContent='⭐ '+p.rating;card.appendChild(z)}if(p.address){const z=document.createElement('div');z.style.cssText='margin-top:7px;font-size:10px;color:#475569;line-height:1.45';z.innerHTML='Address: '+esc(p.address);card.appendChild(z)}if(p.description){const z=document.createElement('div');z.className='shh-card-desc';z.textContent=strip(p.description);card.appendChild(z)}const vals=Array.isArray(p.highlights)?p.highlights.map(strip).filter(Boolean):[];if(vals.length){const h=document.createElement('div');h.className='shh-tags';vals.slice(0,6).forEach(x=>{const z=document.createElement('span');z.className='shh-tag';z.textContent=x;h.appendChild(z)});card.appendChild(h)}const acts=document.createElement('div');acts.className='shh-actions';if(phone){const a=document.createElement('a');a.className='call';a.href='tel:'+encodeURIComponent(phone);a.textContent='📞 Call Owner';acts.appendChild(a)}if(maps){const a=document.createElement('a');a.className='map';a.href=maps;a.target='_blank';a.rel='noopener noreferrer';a.textContent='🗺️ Google Maps';acts.appendChild(a)}if(details){const a=document.createElement('a');a.className='view';a.href=details;a.textContent='View';acts.appendChild(a)}if(acts.children.length)card.appendChild(acts);return card} + function renderPropertyCard(p){const card=document.createElement('div');card.className='shh-card';const loc=[p.area,p.city].filter(Boolean).join(', '),phone=p.phone||p.whatsapp,maps=safeUrl(p.mapsUrl),details=safeUrl(p.detailsUrl);const top=document.createElement('div');top.style.cssText='display:flex;justify-content:space-between;gap:8px;align-items:flex-start';top.innerHTML='
'+icon(p.type)+' '+esc(p.name||'Active listing')+'
'+(loc?'
📍 '+esc(loc)+'
':'')+'
'+(p.verified?'✓ Verified Active':'Active')+'';card.appendChild(top);if(p.distanceLabel){const z=document.createElement("div");z.style.cssText="margin-top:5px;font-size:10px;font-weight:700;color:#475569;line-height:1.4";z.textContent=p.distanceLabel;card.appendChild(z)}if(p.price){const z=document.createElement('div');z.className='shh-card-price';z.textContent=p.price;card.appendChild(z)}if(p.rating!==undefined&&p.rating!==null){const z=document.createElement('div');z.style.cssText='margin-top:5px;font-size:10px;font-weight:900;color:#8a6415';z.textContent='⭐ '+p.rating;card.appendChild(z)}if(p.address){const z=document.createElement('div');z.style.cssText='margin-top:7px;font-size:10px;color:#475569;line-height:1.45';z.innerHTML='Address: '+esc(p.address);card.appendChild(z)}if(p.description){const z=document.createElement('div');z.className='shh-card-desc';z.textContent=strip(p.description);card.appendChild(z)}const vals=Array.isArray(p.highlights)?p.highlights.map(strip).filter(Boolean):[];if(vals.length){const h=document.createElement('div');h.className='shh-tags';vals.slice(0,6).forEach(x=>{const z=document.createElement('span');z.className='shh-tag';z.textContent=x;h.appendChild(z)});card.appendChild(h)}const acts=document.createElement('div');acts.className='shh-actions';if(phone){const a=document.createElement('a');a.className='call';a.href='tel:'+encodeURIComponent(phone);a.textContent='📞 Call Owner';acts.appendChild(a)}if(maps){const a=document.createElement('a');a.className='map';a.href=maps;a.target='_blank';a.rel='noopener noreferrer';a.textContent='🗺️ Google Maps';acts.appendChild(a)}if(details){const a=document.createElement('a');a.className='view';a.href=details;a.textContent='View';acts.appendChild(a)}if(acts.children.length)card.appendChild(acts);return card} function renderData(wrap,data){const recs=Array.isArray(data?.recommendedProperties)?data.recommendedProperties:(Array.isArray(data?.recommendations)?data.recommendations:[]);if(recs.length){const title=document.createElement('div');title.style.cssText='margin-top:9px;font-size:11px;font-weight:900;color:#1e293b';title.textContent='✨ Relevant StudentHubHelp options';wrap.appendChild(title);recs.slice(0,6).forEach(p=>wrap.appendChild(renderPropertyCard(p)))}const chips=Array.isArray(data?.suggestedFollowUps)?data.suggestedFollowUps.filter(Boolean).slice(0,4):[];if(chips.length){const row=document.createElement('div');row.className='shh-follow';chips.forEach(q=>{const b=document.createElement('button');b.type='button';b.textContent=q;b.onclick=()=>{const action=q==='View full details'?'view_details':q==='Search by area'?'search_area':q==='Show more options'?'show_more':q==='Contact support'?'contact_support':'';const first=recs[0];sendText(q,{action,focusPropertyId:first?.id||'',focusPropertyType:first?.type||'',shownPropertyIds:recs.map(x=>x.id)})};row.appendChild(b)});wrap.appendChild(row)}} function showAppointmentForm(){ const wrap=document.createElement('div');wrap.style.cssText='margin-top:9px;background:#fff;border:1px solid #dbe5ef;border-radius:17px;padding:12px;box-shadow:0 7px 22px rgba(15,23,42,.07);'; From 7342b3330609d770030ed0aa95445abdc8637125 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:45:04 +0530 Subject: [PATCH 22/95] Harmonize React chatbot timeout and add voice/retry fallbacks --- ai-chatbot/src/components/ChatWidget.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/ai-chatbot/src/components/ChatWidget.tsx b/ai-chatbot/src/components/ChatWidget.tsx index ff2380159..82f0a8613 100644 --- a/ai-chatbot/src/components/ChatWidget.tsx +++ b/ai-chatbot/src/components/ChatWidget.tsx @@ -64,6 +64,8 @@ export const ChatWidget: React.FC = ({ const [inputText, setInputText] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isListening, setIsListening] = useState(false); + const [isSpeechSupported, setIsSpeechSupported] = useState(false); + const [retryText, setRetryText] = useState(null); const [isSpeakingEnabled, setIsSpeakingEnabled] = useState(true); const [unreadCount, setUnreadCount] = useState(0); const [recentLeadAlert, setRecentLeadAlert] = useState(null); @@ -87,7 +89,8 @@ export const ChatWidget: React.FC = ({ useEffect(() => { if (isOpen) { scrollToBottom(); setUnreadCount(0); } }, [messages, isOpen]); useEffect(() => { - const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + const SpeechRecognition = typeof window !== "undefined" ? ((window as any).SpeechRecognition || (window as any).webkitSpeechRecognition) : undefined; + setIsSpeechSupported(Boolean(SpeechRecognition)); if (SpeechRecognition) { const recognition = new SpeechRecognition(); recognition.continuous = false; recognition.interimResults = false; recognition.lang = 'hi-IN'; recognition.onresult = (event: any) => { const transcript = event.results[0][0].transcript; setInputText(prev => prev ? `${prev} ${transcript}` : transcript); setIsListening(false); }; @@ -96,7 +99,7 @@ export const ChatWidget: React.FC = ({ }, []); const toggleSpeechRecognition = () => { - if (!recognitionRef.current) { alert('Browser voice recognition is not supported in this browser. Please use Chrome, Edge or Safari.'); return; } + if (!recognitionRef.current) return; if (isListening) { recognitionRef.current.stop(); setIsListening(false); } else { try { recognitionRef.current.start(); setIsListening(true); } catch { setIsListening(false); } } }; const speakText = (text: string) => { @@ -106,7 +109,7 @@ export const ChatWidget: React.FC = ({ }; const handleSendMessage = async (textToSend?: string, topicOverride?: string) => { - const text = (textToSend || inputText).trim(); if (!text || isLoading) return; const topicToPrioritize = topicOverride || activeTopic; + const text = (textToSend || inputText).trim(); if (!text || isLoading) return; const topicToPrioritize = topicOverride || activeTopic; setRetryText(null); const userMessage: ChatMessage = { id: `user-${Date.now()}`, sender: 'user', text, timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }; setMessages(prev => [...prev, userMessage]); setInputText(''); setIsLoading(true); try { @@ -121,7 +124,7 @@ export const ChatWidget: React.FC = ({ }) || lastPropertyMessage?.recommendedProperties?.[0] : undefined; if (!SUPABASE_ANON_KEY) throw new Error('Missing VITE_SUPABASE_ANON_KEY. Add the public Supabase anon/publishable key to the Vite build environment.'); - const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 20000); let res: Response; + const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 15000); let res: Response; try { res = await fetch(CHAT_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}` }, body: JSON.stringify({ message: text, @@ -132,11 +135,11 @@ export const ChatWidget: React.FC = ({ focusPropertyType: focusedProperty?.type ? String(focusedProperty.type) : undefined }), signal: controller.signal }); } finally { window.clearTimeout(timeoutId); } - if (!res.ok) { let detail = ''; try { const errorBody = await res.json(); detail = errorBody?.error || errorBody?.message || ''; } catch {} throw new Error(detail || `Chat request failed (${res.status})`); } + if (!res.ok) { let detail = '', errorId = ''; try { const errorBody = await res.json(); detail = errorBody?.error || errorBody?.message || ''; errorId = errorBody?.error_id || ''; } catch {} const failure: any = new Error(detail || `Chat request failed (${res.status})`); failure.status = res.status; failure.errorId = errorId; throw failure; } const data: ChatApiResponse = await res.json(); if (!data || typeof data.reply !== 'string' || !data.reply.trim()) throw new Error('Chat backend returned an invalid response.'); const botMessage: ChatMessage = { id: `bot-${Date.now()}`, sender: 'bot', text: data.reply, timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), diagnosis: { intent: data.intent, primaryTopic: data.primaryTopic, topicDirectAnswer: data.topicDirectAnswer, topicPriorityReason: data.topicPriorityReason, userNeedSummary: data.userNeedSummary, sentiment: data.sentiment as SentimentType, suggestedFollowUps: data.suggestedFollowUps || [] }, recommendedProperties: data.recommendedProperties || data.recommendations || [], budgetPlan: data.budgetPlan, comparisonMatrix: data.comparisonMatrix }; setMessages(prev => [...prev, botMessage]); if (!isOpen) setUnreadCount(prev => prev + 1); if (data.capturedLead) { setRecentLeadAlert('Lead captured from this conversation.'); onLeadCaptured?.(data.capturedLead); } onDiagnosisUpdate?.(botMessage.diagnosis, text); setActiveTopic(null); speakText(data.reply); - } catch (err: any) { setMessages(prev => [...prev, { id: `bot-error-${Date.now()}`, sender: 'bot', text: err?.name === 'AbortError' ? 'The response took too long. Please try again.' : (err?.message || 'Chat service is temporarily unavailable.'), timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); } + } catch (err: any) { if (err?.errorId) console.error('[StudentHubHelp chatbot error]', err.errorId); if (err?.name === 'AbortError' || Number(err?.status) >= 500) setRetryText(text); setMessages(prev => [...prev, { id: `bot-error-${Date.now()}`, sender: 'bot', text: err?.name === 'AbortError' ? 'The response took too long. Please try again.' : (err?.message || 'Chat service is temporarily unavailable.'), timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); } finally { setIsLoading(false); } }; const handleQuickTopic = (topic: typeof QUICK_TOPICS[number]) => { setActiveTopic(topic.topic); void handleSendMessage(topic.query, topic.topic); }; @@ -361,8 +364,8 @@ export const ChatWidget: React.FC = ({ {isOpen &&
SH
StudentHubHelp AI
Live listing assistant
Live Supabase data · Smart assistant
{QUICK_TOPICS.map(topic=>)}
-
{recentLeadAlert&&
{recentLeadAlert}
}{messages.map(m=>
{m.sender==='user'?:}{m.sender==='user'?'You':'StudentHubHelp AI'}{m.timestamp}
{m.text}
{m.diagnosis?.primaryTopic&&m.sender==='bot'&&
Topic: {m.diagnosis.primaryTopic}
}{m.recommendedProperties?.length>0&&
{m.recommendedProperties.map(renderPropertyCard)}
}{m.budgetPlan&&
}{m.comparisonMatrix&&
}{m.diagnosis?.suggestedFollowUps?.length>0&&m.sender==='bot'&&
{m.diagnosis.suggestedFollowUps.slice(0,4).map((f:string)=>)}
}
)}{isLoading&&
AI is checking live listings…
}
-
setInputText(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type any topic (Hostel, Tiffin, Library, Rates)..." className="min-w-0 flex-1 h-11 rounded-xl border border-slate-200 px-2.5 text-sm outline-none focus:border-amber-400"/>
Live listing support
+
{recentLeadAlert&&
{recentLeadAlert}
}{messages.map(m=>
{m.sender==='user'?:}{m.sender==='user'?'You':'StudentHubHelp AI'}{m.timestamp}
{m.text}
{m.diagnosis?.primaryTopic&&m.sender==='bot'&&
Topic: {m.diagnosis.primaryTopic}
}{m.recommendedProperties?.length>0&&
{m.recommendedProperties.map(renderPropertyCard)}
}{m.budgetPlan&&
}{m.comparisonMatrix&&
}{m.diagnosis?.suggestedFollowUps?.length>0&&m.sender==='bot'&&
{m.diagnosis.suggestedFollowUps.slice(0,4).map((f:string)=>)}
}
)}{isLoading&&
AI is checking live listings…
}{retryText&&!isLoading&&
}
+
setInputText(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type any topic (Hostel, Tiffin, Library, Rates)..." className="min-w-0 flex-1 h-11 rounded-xl border border-slate-200 px-2.5 text-sm outline-none focus:border-amber-400"/>
Live listing support
}
; }; From 417b42ccbc1991ef675c280cfe0e3f04b1c52661 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:46:13 +0530 Subject: [PATCH 23/95] Align legacy local chatbot Gemini timeout and clear timer --- ai-chatbot/server.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ai-chatbot/server.ts b/ai-chatbot/server.ts index 8e8d0d7fe..18c4fdcc8 100644 --- a/ai-chatbot/server.ts +++ b/ai-chatbot/server.ts @@ -623,8 +623,9 @@ CRITICAL DATA SAFETY: parts: [{ text: preferredTopic ? `[Priority Focus: ${preferredTopic}] ${message}` : message }] }); + let timeoutId: ReturnType; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Gemini API timeout after 6 seconds')), 6000); + timeoutId = setTimeout(() => reject(new Error('Gemini API timeout after 8 seconds')), 8000); }); const apiPromise = ai.models.generateContent({ @@ -699,7 +700,9 @@ CRITICAL DATA SAFETY: } }); - const response = (await Promise.race([apiPromise, timeoutPromise])) as any; + let response: any; + try { response = await Promise.race([apiPromise, timeoutPromise]); } + finally { clearTimeout(timeoutId!); } const latencyMs = Date.now() - startTime; const parsed = JSON.parse(response.text?.trim() || '{}'); From ffd5c0457844668522a24be58fa9147a1794eb1a Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:46:24 +0530 Subject: [PATCH 24/95] Document authoritative Edge Function chatbot architecture --- ai-chatbot/README.md | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/ai-chatbot/README.md b/ai-chatbot/README.md index e8f317d45..d48ea4e63 100644 --- a/ai-chatbot/README.md +++ b/ai-chatbot/README.md @@ -1,39 +1,34 @@ # StudentHubHelp AI Chatbot -Production-oriented StudentHubHelp AI chatbot based on Gemini 3.8 Flash and live Supabase data. +StudentHubHelp's public chatbot is served by the Supabase Edge Function and reads active listings from the category-specific Supabase tables. -## Data rule +## Production request path -Property recommendations are loaded from the StudentHubHelp public property tables: +- Public website embed: `widget.js` -> `/functions/v1/studenthubhelp-chat` +- React chatbot: `ai-chatbot/src/components/ChatWidget.tsx` -> the same Edge Function +- Edge Function: authoritative runtime config, rate limits, deterministic listing search, and optional Gemini response enhancement +- Supabase: `hostels`, `tiffins`, `libraries`, `cafes`, and `bookstores` -- `hostels` -- `tiffins` -- `libraries` -- `cafes` -- `bookstores` +Both browser clients use the same production endpoint. Do not point either client at the legacy Express `/api/chat` route. -Only rows whose `status` is `active` are exposed to the chatbot. No property records, prices, ratings, contacts or recommendations are hardcoded as demo data. +## Database and security + +- Only rows with `status = 'active'` are eligible for public recommendations. +- The Edge Function uses the private `chatbot_rate_limits` table through the `consume_chatbot_rate_limit` security-definer RPC. Apply the checked-in migration before deploying the Edge Function version that calls it. +- Keep the Supabase service-role key server-side only. Browser clients use the public/publishable key. +- The chatbot fails closed when its admin config is missing/unavailable or a trusted Cloudflare client-IP header is absent. ## Environment -Set: +For the Edge Function, configure the Supabase URL/key and Gemini key as Supabase Function secrets. The React build accepts `VITE_CHAT_API_URL` and `VITE_SUPABASE_ANON_KEY`; the default chat URL is the production Edge Function. -- `GEMINI_API_KEY` -- `SUPABASE_URL` -- `SUPABASE_ANON_KEY` -- `APP_URL` (optional) +## Legacy Express preview -The Supabase key is used server-side for public active-listing reads. Never expose a service-role key in browser code. +`server.ts` and its `/api/*` routes are a legacy local preview harness, not the production source of truth. Its config and lead arrays are in-memory and reset when the process restarts. Do not deploy those endpoints as a public production API or use them for authoritative chatbot settings, leads, or property data. -## Local run +Run locally only when intentionally testing the legacy preview: ```bash npm install npm run dev ``` - -## Production architecture - -GitHub Pages / public website -> embedded `public/widget.js` -> deployed Node/Express API -> Gemini + Supabase. - -The current project is prepared as the chatbot application. Public-site embedding and the admin AI section should be integrated after the live backend deployment endpoint is chosen. From 5d93c53de8d4676e68741521316599308ff9952a Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:46:46 +0530 Subject: [PATCH 25/95] Enforce budget and area-distance integrity in comparison results --- supabase/functions/studenthubhelp-chat/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 5dafff7cc..7ac277800 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -319,11 +319,12 @@ Deno.serve(async(req:Request)=>{ } } if(cfg.comparison_enabled&&(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg)))&&!isBookingInfoQuestion(msg)){ - const decisionRows=await multiDirect([c||"hostel"],ct); + const decisionRows=await multiDirect([c||"hostel"],ct,u.budgetMax??undefined); const decisionLandmark=findLandmark(near||""); - if(decisionLandmark){for(const p of decisionRows){const d=distanceToLandmark(p,decisionLandmark);if(d!==undefined)p._distanceKm=d}} + if(decisionLandmark){for(const p of decisionRows){const d=distanceToLandmark(p,decisionLandmark);if(d!==undefined)p._distanceKm=d;else if(searchText(p).includes(n(decisionLandmark.key)))p._nearbyAreaMatch=true}} let decision=decisionRows.filter(p=>!shownIds.has(String(p.id))); - if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||searchText(p).includes(n(decisionLandmark.key)))); + if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||p._nearbyAreaMatch===true)); + if(u.budgetMax!==null||u.budgetMin!==null)decision=decision.filter(p=>{const v=budgetValue(p);return v!==undefined&&(u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); const rankedDecision=decision.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_results).map(card); const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near,cfg,requestDeadlineAt); const reply=ar.reply||"Aapki requirement ke hisaab se live options compare kar sakte hain."; From 223bd8b14753d34c300b68371a32de011384d015 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:47:08 +0530 Subject: [PATCH 26/95] Add chatbot Edge Function and React PR validation --- .github/workflows/chatbot-pr-check.yml | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/chatbot-pr-check.yml diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml new file mode 100644 index 000000000..57fd1126f --- /dev/null +++ b/.github/workflows/chatbot-pr-check.yml @@ -0,0 +1,43 @@ +name: Chatbot PR validation + +on: + pull_request: + paths: + - 'supabase/functions/studenthubhelp-chat/**' + - 'supabase/migrations/*chatbot*' + - 'ai-chatbot/**' + - 'widget.js' + - '.github/workflows/chatbot-pr-check.yml' + +permissions: + contents: read + +jobs: + edge-function-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Type-check Supabase Edge Function + run: deno check supabase/functions/studenthubhelp-chat/index.ts + + react-chatbot-check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ai-chatbot + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + cache-dependency-path: ai-chatbot/package-lock.json + - name: Install dependencies + run: npm install + - name: Type-check React chatbot + run: npm run lint + - name: Build chatbot + run: npm run build From c7af7c4423f18ff8bb75218e4867c7bc9e8bd948 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:47:18 +0530 Subject: [PATCH 27/95] Make legacy server timeout cleanup type-safe --- ai-chatbot/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-chatbot/server.ts b/ai-chatbot/server.ts index 18c4fdcc8..9f816a5d6 100644 --- a/ai-chatbot/server.ts +++ b/ai-chatbot/server.ts @@ -623,7 +623,7 @@ CRITICAL DATA SAFETY: parts: [{ text: preferredTopic ? `[Priority Focus: ${preferredTopic}] ${message}` : message }] }); - let timeoutId: ReturnType; + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Gemini API timeout after 8 seconds')), 8000); }); @@ -702,7 +702,7 @@ CRITICAL DATA SAFETY: let response: any; try { response = await Promise.race([apiPromise, timeoutPromise]); } - finally { clearTimeout(timeoutId!); } + finally { if (timeoutId) clearTimeout(timeoutId); } const latencyMs = Date.now() - startTime; const parsed = JSON.parse(response.text?.trim() || '{}'); From f2c770b01ae9ef68e02b5c359c94dace2b724e1a Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:47:42 +0530 Subject: [PATCH 28/95] Validate trusted IP and briefly cache fail-closed config --- supabase/functions/studenthubhelp-chat/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 7ac277800..970af566c 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -9,13 +9,13 @@ const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["c const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60});return keys} -async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){if(!req.headers.get("cf-connecting-ip")?.trim())throw Error("trusted Cloudflare client IP header unavailable");for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} +async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){const trustedIp=req.headers.get("cf-connecting-ip")?.trim()||"";if(!trustedIp||trustedIp.length>100)throw Error("trusted Cloudflare client IP header unavailable or invalid");for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; const SAFE_FALLBACK_CONFIG:ChatbotRuntimeConfig={enabled:false,ai_rerank_enabled:false,lead_capture_enabled:false,comparison_enabled:false,budget_planner_enabled:false,max_candidates:10,max_results:1,max_output_tokens:200,temperature:0,maintenance_message:"Chatbot system maintenance par hai. Kripya thodi der baad koshish karein ya seedhe support par sampark karein."}; let runtimeConfigCache:{value:ChatbotRuntimeConfig;expiresAt:number}|null=null; -async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+2000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);return SAFE_FALLBACK_CONFIG}} +async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");runtimeConfigCache={value:SAFE_FALLBACK_CONFIG,expiresAt:Date.now()+2000};return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+2000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);runtimeConfigCache={value:SAFE_FALLBACK_CONFIG,expiresAt:Date.now()+2000};return SAFE_FALLBACK_CONFIG}} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} function findLandmark(s:string){const x=n(normalizeSearchTerms(s));for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} @@ -256,7 +256,7 @@ Deno.serve(async(req:Request)=>{ let b:any;try{b=JSON.parse(rawBody)}catch{return new Response(JSON.stringify({error:"Invalid JSON request."}),{status:400,headers:H})} const msg=String(b?.message||"").trim().slice(0,1500),history=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; if(!msg)return new Response(JSON.stringify({error:"Message is required"}),{status:400,headers:H}); - const sessionId=String(b?.sessionId||crypto.randomUUID()); + const sessionId=String(b?.sessionId||crypto.randomUUID()).slice(0,160); try{if(!await consumeDualRateLimit("chat",req,sessionId,30,60))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Chat service temporarily unavailable."}),{status:503,headers:H})} const cfg=await getRuntimeConfig(); if(!cfg.enabled)return new Response(JSON.stringify({reply:cfg.maintenance_message||"StudentHubHelp AI assistant is temporarily unavailable. Please try again shortly.",grounded:false,maintenance:true}),{status:503,headers:H}); From f9e48742c51b4f731998c624ddd959dcb77330e6 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:48:25 +0530 Subject: [PATCH 29/95] Verify admin settings update under existing RLS policy --- admin/src/components/ChatbotAdminControls.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/admin/src/components/ChatbotAdminControls.tsx b/admin/src/components/ChatbotAdminControls.tsx index c84a4f9f2..d6ca5e5f5 100644 --- a/admin/src/components/ChatbotAdminControls.tsx +++ b/admin/src/components/ChatbotAdminControls.tsx @@ -111,9 +111,9 @@ export const ChatbotAdminControls: React.FC = ({ const { data: userData } = await supabase.auth.getUser(); const updatedBy = userData.user?.email || 'admin'; const payload = { ...config, updated_by: updatedBy, updated_at: new Date().toISOString() }; - const { data, error } = await supabase.from('chatbot_admin_config').upsert(payload, { onConflict: 'id' }).select('id, updated_at').single(); + const { data, error } = await supabase.from('chatbot_admin_config').update(payload).eq('id', 1).select('id, updated_at').single(); if (error) throw error; - if (!data || Number(data.id) !== 1) throw new Error('Settings save verify nahi ho payi.'); + if (!data || Number(data.id) !== 1) throw new Error('Settings save verify nahi ho payi. Config row id=1 missing ya access denied ho sakta hai.'); setConfig({ ...payload, updated_at: data.updated_at || payload.updated_at }); setMessage('AI control settings saved and verified in Supabase.'); } catch (e: any) { From 3593a23d87e9424b9c3cf6200f9087f7a2c3761b Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:49:42 +0530 Subject: [PATCH 30/95] Resolve chatbot Edge Function type-check errors --- .../functions/studenthubhelp-chat/index.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 970af566c..5269f1080 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -1,5 +1,7 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +declare const EdgeRuntime: { waitUntil(promise: Promise): void }; + const U=(Deno.env.get("SUPABASE_URL")||"").replace(/\/$/,""); const K=Deno.env.get("SUPABASE_SECRET_KEY")||Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")||""; const GEMINI=Deno.env.get("GEMINI_API_KEY")||""; @@ -14,6 +16,7 @@ async function consumeRateLimit(scope:string,key:string,limit:number,windowSecon async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; const SAFE_FALLBACK_CONFIG:ChatbotRuntimeConfig={enabled:false,ai_rerank_enabled:false,lead_capture_enabled:false,comparison_enabled:false,budget_planner_enabled:false,max_candidates:10,max_results:1,max_output_tokens:200,temperature:0,maintenance_message:"Chatbot system maintenance par hai. Kripya thodi der baad koshish karein ya seedhe support par sampark karein."}; +const DEFAULT_RUNTIME_CONFIG:ChatbotRuntimeConfig=SAFE_FALLBACK_CONFIG; let runtimeConfigCache:{value:ChatbotRuntimeConfig;expiresAt:number}|null=null; async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");runtimeConfigCache={value:SAFE_FALLBACK_CONFIG,expiresAt:Date.now()+2000};return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+2000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);runtimeConfigCache={value:SAFE_FALLBACK_CONFIG,expiresAt:Date.now()+2000};return SAFE_FALLBACK_CONFIG}} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; @@ -67,7 +70,9 @@ function fallbackReply(c:string|undefined,recs:any[],near:string,cityName:string function safeJson(s:string){try{return JSON.parse(s)}catch{const m=s.match(/\{[\s\S]*\}/);if(m)try{return JSON.parse(m[0])}catch{}return null}} const SEARCH_ALIASES:[RegExp,string][]=[[/\b(?:piparli\s+rd|piprali\s+rd|pipli\s+road|piprali)\b/gi,"piprali road"],[/\b(?:allun|allan|allen\s+institute|allen\s+career)\b/gi,"allen"],[/\b(?:sasta|saste|kam\s+budget)\b/gi,"budget"],[/\b(?:ladko\s+ka|ladkon\s+ka)\b/gi,"boys"],[/\b(?:ladkiyo\s+ka|ladkiyon\s+ka)\b/gi,"girls"]]; function normalizeSearchTerms(input:string){let value=String(input||"").normalize("NFKC");for(const [pattern,replacement] of SEARCH_ALIASES)value=value.replace(pattern,replacement);return value} -function aiIntentDefaults(){return{intent:"general",confidence:0,category:"",categories:[],city:"",locality:"",area:"",landmark:"",nearRelation:"",gender:"",budgetMin:null,budgetMax:null,facilities:[],referenceIndex:null,action:"",followUp:false,correction:false,needsClarification:false,clarificationQuestion:"",searchScope:""}} +type ChatIntent={intent:string;confidence:number;category:string;categories:string[];city:string;locality:string;area:string;landmark:string;nearRelation:string;gender:string;budgetMin:number|null;budgetMax:number|null;facilities:string[];referenceIndex:number|null;action:string;followUp:boolean;correction:boolean;needsClarification:boolean;clarificationQuestion:string;searchScope:string}; +type StrictLocation={city?:string;locality?:string;area?:string;landmark?:string}; +function aiIntentDefaults():ChatIntent{return{intent:"general",confidence:0,category:"",categories:[],city:"",locality:"",area:"",landmark:"",nearRelation:"",gender:"",budgetMin:null,budgetMax:null,facilities:[],referenceIndex:null,action:"",followUp:false,correction:false,needsClarification:false,clarificationQuestion:"",searchScope:""}} function offlineIntent(msg:string,history:any[]){ const o=aiIntentDefaults(),x=n(normalizeSearchTerms(msg)),defs:[string,RegExp][]=[["hostel",/hostel|\bpg\b|room|stay|accommodation|girls hostel|boys hostel|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food service|टिफिन|मेस/],["library",/library|reading room|study room|लाइब्रेरी/],["cafe",/cafe|coffee shop|कैफे/],["bookstore",/bookstore|book shop|bookshop|stationery|बुकस्टोर/]]; const cats=defs.filter(([,re])=>re.test(x)).map(([c])=>c); @@ -101,14 +106,14 @@ function normalizeIntent(x:any){ o.referenceIndex=Number.isInteger(Number(o.referenceIndex))?Number(o.referenceIndex):null;o.confidence=Math.max(0,Math.min(1,Number(o.confidence)||0)); o.followUp=!!o.followUp;o.correction=!!o.correction;o.needsClarification=!!o.needsClarification;return o; } -function deterministicLocation(s:string){ +function deterministicLocation(s:string):StrictLocation{ const x=n(s); if(/\b(talwandi|tilwandi|तालवंडी|तलवंडी)\b/i.test(x))return{city:"Kota",locality:"Talwandi"}; if(/nawalgarh road|nawalgarh rd|nawalgarh|नवलगढ़ रोड|नवलगढ रोड/i.test(x))return{city:"Sikar",area:"Nawalgarh Road"}; if(/piprali road|piprali rd|piprali|पिपराली रोड|पिपराली/i.test(x))return{city:"Sikar",area:"Piprali Road"}; const lm=findLandmark(x);if(lm)return{city:lm.city,landmark:lm.key};return{}; } -function strictLocation(s:string){ +function strictLocation(s:string):StrictLocation{ const normalized=normalizeSearchTerms(s),d=deterministicLocation(normalized),x=n(normalized); if(d.city||d.locality||d.area||d.landmark)return d; const c=city(normalized);return c?{city:c}:{}; @@ -254,7 +259,7 @@ Deno.serve(async(req:Request)=>{ const contentLength=Number(req.headers.get("content-length")||0);if(contentLength>32768)return new Response(JSON.stringify({error:"Request is too large."}),{status:413,headers:H}); const rawBody=await req.text();if(new TextEncoder().encode(rawBody).byteLength>32768)return new Response(JSON.stringify({error:"Request is too large."}),{status:413,headers:H}); let b:any;try{b=JSON.parse(rawBody)}catch{return new Response(JSON.stringify({error:"Invalid JSON request."}),{status:400,headers:H})} - const msg=String(b?.message||"").trim().slice(0,1500),history=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; + const msg:string=String(b?.message||"").trim().slice(0,1500),history:{role:string;text:string}[]=Array.isArray(b?.history)?b.history.slice(-8).map((x:any)=>({role:String(x?.role||"").slice(0,20),text:String(x?.text||"").slice(0,1500)})):[]; if(!msg)return new Response(JSON.stringify({error:"Message is required"}),{status:400,headers:H}); const sessionId=String(b?.sessionId||crypto.randomUUID()).slice(0,160); try{if(!await consumeDualRateLimit("chat",req,sessionId,30,60))return new Response(JSON.stringify({error:"Too many requests. Please try again shortly."}),{status:429,headers:{...H,"Retry-After":"60"}})}catch(e){console.error(e);return new Response(JSON.stringify({error:"Chat service temporarily unavailable."}),{status:503,headers:H})} @@ -285,7 +290,7 @@ Deno.serve(async(req:Request)=>{ const requestedFocusId=String(b?.focusPropertyId||""); const requestedFocusType=String(b?.focusPropertyType||""); if((isPhoneIntent(msg)||isDetailIntent(msg))&&requestedFocusId){ - const focusHistory=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "); + const focusHistory=history.filter((x:{role:string;text:string})=>x.role==="user").map((x:{role:string;text:string})=>x.text||"").join(" "); const focusCombined=focusHistory+" "+msg; const focusType=requestedFocusType||cat(focusCombined)||"hostel"; const focusRows=await multiDirect([focusType],city(focusCombined)||""); @@ -310,12 +315,12 @@ Deno.serve(async(req:Request)=>{ if(u.needsClarification&&u.clarificationQuestion&&!u.category&&!u.categories.length&&!u.locality&&!u.area&&!u.landmark)return await chatResponse({reply:u.clarificationQuestion,intent:"Clarification",primaryTopic:"Requirement clarification",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a hostel","Find a library","Find a cafe","Find a tiffin service"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.intent==="property_reference"&&u.referenceIndex){ const all=await multiDirect(["hostel","tiffin","library","cafe","bookstore"],ct); - const ordered=shownList.length?shownList.map(id=>all.find(p=>String(p.id)===id)).filter(Boolean):[]; + const ordered=shownList.length?shownList.map((id:string)=>all.find(p=>String(p.id)===id)).filter(Boolean):[]; const target=ordered[u.referenceIndex-1]||all.find(p=>String(p.id)===shownList[u.referenceIndex-1]); if(target){ const fp=card(target),wantPhone=u.action==="phone"||isPhoneIntent(msg); if(wantPhone){const ph=String(fp.phone||"").trim();const reply=ph&&ph.replace(/\\D/g,"").length>=7?"Ji 😊 **"+fp.name+"** ka listed phone number: **"+ph+"**":"Is listing ka valid phone number abhi StudentHubHelp data me available nahi hai.";return await chatResponse({reply,intent:"Property Contact",primaryTopic:"Referenced property contact",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iske full details batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg)} - const ar=await aiProperty(msg,history,[fp],fp._type,near,cfg,requestDeadlineAt),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) + const ar=await aiProperty(msg,history,[fp],fp.type,near,cfg,requestDeadlineAt),reply=ar.reply||("Bilkul 😊 **"+fp.name+"** ki available details neeche di hain.");return await chatResponse({reply,intent:"Property Reference",primaryTopic:"Referenced property",recommendedProperties:[fp],recommendations:[fp],suggestedFollowUps:["Iska phone number batao","Aur options dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg) } } if(cfg.comparison_enabled&&(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg)))&&!isBookingInfoQuestion(msg)){ From 525857d538857abfc05f695d3e5a0041f17a0f0c Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:50:06 +0530 Subject: [PATCH 31/95] Scope React type-check to chatbot component --- .github/workflows/chatbot-pr-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index 57fd1126f..74883f628 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -37,7 +37,7 @@ jobs: cache-dependency-path: ai-chatbot/package-lock.json - name: Install dependencies run: npm install - - name: Type-check React chatbot - run: npm run lint + - name: Type-check React chatbot component + run: npx tsc --noEmit --skipLibCheck --jsx react-jsx --target ES2020 --module ESNext --moduleResolution bundler --allowImportingTsExtensions --types vite/client src/components/ChatWidget.tsx - name: Build chatbot run: npm run build From 6a5259757087ec356d0d01a66b869907242c432e Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:50:50 +0530 Subject: [PATCH 32/95] Push city, area, gender and budget filters into category queries --- .../functions/studenthubhelp-chat/index.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 5269f1080..b38ecf12f 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -29,8 +29,35 @@ function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude?? function distanceKm(a:any,b:any){const A=coords(a),B=coords(b);if(!A||!B)return undefined;const r=(v:number)=>v*Math.PI/180,dLat=r(B.lat-A.lat),dLon=r(B.lng-A.lng),h=Math.sin(dLat/2)**2+Math.cos(r(A.lat))*Math.cos(r(B.lat))*Math.sin(dLon/2)**2;return 6371*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h))} function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,longitude:l.lng}:null;if(!A||!B)return undefined;return distanceKm(A,B)} function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.city,p.area,p.service_area,p.address,p.landmark,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} -async function direct(type:string,ct?:string,maxBudget?:number){const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return [];const all:any[]=[];let offset=0;const pageSize=500;while(true){const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),offset:String(offset)});if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`);if(maxBudget!==undefined&&Number.isFinite(maxBudget)&&maxBudget>0){const budget=String(Math.floor(maxBudget));const priceFilter=type==="hostel"?`(monthly_rent.lte.${budget},and(monthly_rent.is.null,monthly_fee.lte.${budget}),and(monthly_rent.is.null,monthly_fee.is.null,price.lte.${budget}))`:`(monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget}))`;params.set("or",priceFilter)}const rows=await db(`/rest/v1/${table}?${params.toString()}`);const page=Array.isArray(rows)?rows:[];all.push(...page.map(p=>({...p,_type:type})));if(page.length=10000)break}return all.filter(p=>!ct||n(p.city||"").includes(n(ct)))} -async function multiDirect(types:string[],ct?:string,maxBudget?:number){const unique=[...new Set(types.filter(Boolean))];const groups=await Promise.all(unique.map(type=>direct(type,ct,maxBudget)));return groups.flat()} +async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ + const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return []; + const params=new URLSearchParams({select:"*",status:"eq.active",limit:"500",offset:"0"}); + if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`); + const orGroups:string[]=[]; + if(maxBudget!==undefined&&Number.isFinite(maxBudget)&&maxBudget>0){ + const budget=String(Math.floor(maxBudget)); + orGroups.push(type==="hostel" + ? `monthly_rent.lte.${budget},and(monthly_rent.is.null,monthly_fee.lte.${budget}),and(monthly_rent.is.null,monthly_fee.is.null,price.lte.${budget})` + : `monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget})`); + } + const term=String(locationTerm||"").replace(/[^a-zA-Z0-9\\u0900-\\u097f -]/g," ").replace(/\\s+/g," ").trim(); + if(term)orGroups.push(["name","area","address","landmark","nearby_coaching","description"].map(col=>`${col}.ilike.*${term}*`).join(",")); + if(type==="hostel"&&(genderFilter==="girls"||genderFilter==="boys")){ + const variants=genderFilter==="girls"?["girls","girl","female","women","ladies","ladki","महिला","लड़क"]:["boys","boy","male","men","ladke","लड़के"]; + orGroups.push(variants.flatMap(v=>["name","description","type"].map(col=>`${col}.ilike.*${v}*`)).join(",")); + } + if(orGroups.length===1)params.set("or",`(${orGroups[0]})`); + else if(orGroups.length>1)params.set("and",`(${orGroups.map(group=>`or(${group})`).join(",")})`); + const rows=await db(`/rest/v1/${table}?${params.toString()}`); + const page=Array.isArray(rows)?rows:[]; + // Current category tables contain fewer than 500 rows each; keep the Edge Function candidate set bounded. + return page.map(p=>({...p,_type:type})).filter(p=>!ct||n(p.city||"").includes(n(ct))); +} +async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ + const unique=[...new Set(types.filter(Boolean))]; + const groups=await Promise.all(unique.map(type=>direct(type,ct,maxBudget,locationTerm,genderFilter))); + return groups.flat(); +} function genderWanted(s:string){const x=n(normalizeSearchTerms(s));if(/\b(girls|girl|female|ladki|ladkiyo|ladkiyon|महिला|लड़क|लड़की|girls hostel)\b/i.test(x))return "girls";if(/\b(boys|boy|male|ladke|ladkon|लड़के|लड़का|boys hostel)\b/i.test(x))return "boys";return ""} function areaWanted(s:string){const x=n(normalizeSearchTerms(s));if(/nawalgarh road|nawalgarh rd|नवलगढ़ रोड/i.test(x))return "nawalgarh";if(/piprali road|piprali rd|पिपराली रोड/i.test(x))return "piprali";return ""} function priceLabel(p:any,c:string){const v=Number(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price);if(!Number.isFinite(v)||v<=0)return "";if(c==="hostel"||c==="tiffin"||c==="library")return "₹"+v.toLocaleString("en-IN")+"/mo";return "₹"+v.toLocaleString("en-IN")} @@ -324,7 +351,7 @@ Deno.serve(async(req:Request)=>{ } } if(cfg.comparison_enabled&&(/(?:konsa|kaunsa|kaunsi|which|should i|chahiye).{0,35}(book|hostel|pg)|(?:book|hostel|pg).{0,35}(konsa|kaunsa|kaunsi|which|should)/i.test(n(msg)))&&!isBookingInfoQuestion(msg)){ - const decisionRows=await multiDirect([c||"hostel"],ct,u.budgetMax??undefined); + const decisionRows=await multiDirect([c||"hostel"],ct,u.budgetMax??undefined,u.area||u.locality||undefined,u.gender||genderWanted(msg)); const decisionLandmark=findLandmark(near||""); if(decisionLandmark){for(const p of decisionRows){const d=distanceToLandmark(p,decisionLandmark);if(d!==undefined)p._distanceKm=d;else if(searchText(p).includes(n(decisionLandmark.key)))p._nearbyAreaMatch=true}} let decision=decisionRows.filter(p=>!shownIds.has(String(p.id))); @@ -390,7 +417,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(isPropertyAdviceQuestion(msg)){const adviceCat=cat(msg)||cat(previous)||"hostel";return await chatResponse({reply:adviceReply(adviceCat),intent:"Property Selection Guidance",primaryTopic:label(adviceCat)+" selection guidance",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a property","Search by area","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg,requestDeadlineAt);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(!propertyMode){const ar=await aiGeneral(msg,history,cfg,requestDeadlineAt);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} - let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined); + let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined,u.area||u.locality||undefined,u.gender||genderWanted(msg)); const wantedGender=u.gender||genderWanted(msg),wantedArea=u.area?(/nawalgarh/i.test(u.area)?"nawalgarh":/piprali/i.test(u.area)?"piprali":u.area.toLowerCase()):areaWanted(msg); const wantedLocality=n(u.locality||""); if(wantedGender)rows=rows.filter(p=>{const z=searchText(p);const g=n([p.gender_type,p.gender,p.hostel_type,p.name,p.title,p.description].filter(Boolean).join(" "));return wantedGender==="girls"?/girls|girl|female|women|ladki|ladkiyon|महिला|लड़क/i.test(g):/boys|boy|male|men|ladke|लड़के/i.test(g)}); From 6005d211bcdbf3e3b87c1247375fc6ae4d1e4b36 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:51:06 +0530 Subject: [PATCH 33/95] Include structured listing type and nearby coaching in search text --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index b38ecf12f..bda442b8d 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -28,7 +28,7 @@ function secondaryCats(s:string){const x=n(s),out:string[]=[];const defs:[string function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude??p.latitude_deg),lng=Number(p.longitude??p.lng??p.lon??p.location_longitude??p.longitude_deg);return Number.isFinite(lat)&&Number.isFinite(lng)?{lat,lng}:null} function distanceKm(a:any,b:any){const A=coords(a),B=coords(b);if(!A||!B)return undefined;const r=(v:number)=>v*Math.PI/180,dLat=r(B.lat-A.lat),dLon=r(B.lng-A.lng),h=Math.sin(dLat/2)**2+Math.cos(r(A.lat))*Math.cos(r(B.lat))*Math.sin(dLon/2)**2;return 6371*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h))} function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,longitude:l.lng}:null;if(!A||!B)return undefined;return distanceKm(A,B)} -function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.city,p.area,p.service_area,p.address,p.landmark,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} +function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.type,p.gender_type,p.gender,p.city,p.area,p.service_area,p.address,p.landmark,p.nearby_coaching,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return []; const params=new URLSearchParams({select:"*",status:"eq.active",limit:"500",offset:"0"}); From 28ca5bb9b157c60adc205078f3ada89b5149e7da Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:51:15 +0530 Subject: [PATCH 34/95] Run chatbot validation on isolated branch pushes --- .github/workflows/chatbot-pr-check.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index 74883f628..f210b2ae2 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -1,6 +1,15 @@ name: Chatbot PR validation on: + push: + branches: + - fix/chatbot-reliability-security + paths: + - 'supabase/functions/studenthubhelp-chat/**' + - 'supabase/migrations/*chatbot*' + - 'ai-chatbot/**' + - 'widget.js' + - '.github/workflows/chatbot-pr-check.yml' pull_request: paths: - 'supabase/functions/studenthubhelp-chat/**' From 6cf2d52747072b8f3f1f27055aa2e0ae5257eb16 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:52:16 +0530 Subject: [PATCH 35/95] Preserve Hindi locality text in database search filters --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index bda442b8d..4c4bf030c 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -40,7 +40,7 @@ async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:str ? `monthly_rent.lte.${budget},and(monthly_rent.is.null,monthly_fee.lte.${budget}),and(monthly_rent.is.null,monthly_fee.is.null,price.lte.${budget})` : `monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget})`); } - const term=String(locationTerm||"").replace(/[^a-zA-Z0-9\\u0900-\\u097f -]/g," ").replace(/\\s+/g," ").trim(); + const term=String(locationTerm||"").replace(/[^a-zA-Z0-9\u0900-\u097f -]/g," ").replace(/\s+/g," ").trim(); if(term)orGroups.push(["name","area","address","landmark","nearby_coaching","description"].map(col=>`${col}.ilike.*${term}*`).join(",")); if(type==="hostel"&&(genderFilter==="girls"||genderFilter==="boys")){ const variants=genderFilter==="girls"?["girls","girl","female","women","ladies","ladki","महिला","लड़क"]:["boys","boy","male","men","ladke","लड़के"]; From f68272b3d48eb159d3d0942b4620892c5a8141af Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:52:37 +0530 Subject: [PATCH 36/95] Require explicit area evidence for non-GPS locality matches --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 4c4bf030c..042aa2a18 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -431,7 +431,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(u.facilities?.length)rows=rows.filter(p=>matchesFacility(p,u.facilities)); const landmark=u.landmark?findLandmark(u.landmark)||LANDMARKS.find(l=>l.key===u.landmark):findLandmark(near||""); if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d;else if(searchText(p).includes(n(landmark.key)))p._nearbyAreaMatch=true}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||p._nearbyAreaMatch===true))} - else if(near){const q=n(near);const exact=rows.filter(p=>searchText(p).includes(q));if(exact.length)rows=exact;else if(!wantedArea)rows=[]} + else if(near){const q=n(normalizeSearchTerms(near));const exact=rows.filter(p=>searchText(p).includes(q));if(exact.length){for(const p of exact)if(p._distanceKm===undefined)p._nearbyAreaMatch=true;rows=exact}else rows=[]} if(nearCats.length){const secondaryRows=await multiDirect(nearCats,ct);for(const p of rows){let best=Infinity,hasCoords=false;for(const q of secondaryRows){const d=distanceKm(p,q);if(d!==undefined){hasCoords=true;best=Math.min(best,d)}else if(p.area&&searchText(q).includes(n(p.area)))best=Math.min(best,1)}if(best<=3){p._nearbyDistanceKm=best;p._nearbyVerified=hasCoords;p._nearbyAreaMatch=!hasCoords}}rows=rows.filter(p=>p._nearbyDistanceKm!==undefined)} rows=rows.filter(p=>!shownIds.has(String(p.id))); const ranked=rows.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_candidates); From dfdbd52105190ed6ad5fc312dc4b0d924647eda6 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:52:47 +0530 Subject: [PATCH 37/95] Include hostel type field in gender matching --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 042aa2a18..64f1e390d 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -420,7 +420,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined,u.area||u.locality||undefined,u.gender||genderWanted(msg)); const wantedGender=u.gender||genderWanted(msg),wantedArea=u.area?(/nawalgarh/i.test(u.area)?"nawalgarh":/piprali/i.test(u.area)?"piprali":u.area.toLowerCase()):areaWanted(msg); const wantedLocality=n(u.locality||""); - if(wantedGender)rows=rows.filter(p=>{const z=searchText(p);const g=n([p.gender_type,p.gender,p.hostel_type,p.name,p.title,p.description].filter(Boolean).join(" "));return wantedGender==="girls"?/girls|girl|female|women|ladki|ladkiyon|महिला|लड़क/i.test(g):/boys|boy|male|men|ladke|लड़के/i.test(g)}); + if(wantedGender)rows=rows.filter(p=>{const z=searchText(p);const g=n([p.gender_type,p.gender,p.hostel_type,p.type,p.name,p.title,p.description].filter(Boolean).join(" "));return wantedGender==="girls"?/girls|girl|female|women|ladki|ladkiyon|महिला|लड़क/i.test(g):/boys|boy|male|men|ladke|लड़के/i.test(g)}); // Explicit city is a hard boundary; never leak another city into the answer. if(locationForFilter.city)rows=rows.filter(p=>n(p.city||"")===n(locationForFilter.city)); if(wantedArea)rows=rows.filter(p=>searchText(p).includes(n(wantedArea))); From deb4212fb6e79ae44add469b889fade223ed0893 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:53:17 +0530 Subject: [PATCH 38/95] Parse Hindi numeric budget phrases such as 5000 se kam --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 64f1e390d..90b0d666c 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -106,7 +106,7 @@ function offlineIntent(msg:string,history:any[]){ o.categories=[...new Set(cats)];o.category=o.categories[0]||cat(msg)||""; const loc=strictLocation(msg);o.city=loc.city||city(msg)||"";o.locality=loc.locality||"";o.area=loc.area||"";o.landmark=loc.landmark||""; o.gender=genderWanted(msg); - const budget=x.match(/(?:under|below|within|budget|upto|up to|kam|less than|₹|rs\.?\s*)(\d[\d,]*)/i); + const budget=x.match(/(?:under|below|within|budget|upto|up to|kam|less than|₹|rs\.?\s*)(\d[\d,]*)/i)||x.match(/(\d[\d,]*)\s*(?:se\s*)?(?:kam|ke andar|under|below)/i); if(budget)o.budgetMax=num(budget[1])??null; o.facilities=[...["wifi","wi fi","ac","non ac","cctv","food","mess","parking"].filter(v=>x.includes(n(v)))]; if(o.category||o.categories.length||loc.city||loc.area||loc.locality||loc.landmark||nearTerm(msg)){ From e190d2a7219504c9f27afeabfeaa1abc83e52ea2 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 21:53:51 +0530 Subject: [PATCH 39/95] Restore Vite source entry before chatbot CI build --- .github/workflows/chatbot-pr-check.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index f210b2ae2..58919b74a 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -48,5 +48,21 @@ jobs: run: npm install - name: Type-check React chatbot component run: npx tsc --noEmit --skipLibCheck --jsx react-jsx --target ES2020 --module ESNext --moduleResolution bundler --allowImportingTsExtensions --types vite/client src/components/ChatWidget.tsx + - name: Restore Vite source entry for validation + run: | + cat > index.html <<'EOF' + + + + + + StudentHubHelp AI Chatbot + + +
+ + + + EOF - name: Build chatbot run: npm run build From b3f53a014c63b23785121697320a4a151522be9e Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:06:30 +0530 Subject: [PATCH 40/95] Improve chatbot rate-limit fallback, DB timeouts, combo budgets and paginated search --- .../functions/studenthubhelp-chat/index.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 90b0d666c..9b8d0bfcc 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -10,10 +10,10 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim();const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100)keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60});keys.push({scopeKey:"session:"+String(sessionId||"missing").slice(0,160),limit:15,windowSeconds:60});return keys} -async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){const trustedIp=req.headers.get("cf-connecting-ip")?.trim()||"";if(!trustedIp||trustedIp.length>100)throw Error("trusted Cloudflare client IP header unavailable or invalid");for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} -async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds})});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} -async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"}});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim()||"";const forwarded=req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()||"";const userAgent=(req.headers.get("user-agent")||"unknown-agent").slice(0,80);const stableSession=(req.headers.get("x-session-id")||sessionId||"anonymous").slice(0,160);const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100){keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60})}else{console.warn("[RATE_LIMIT] trusted cf-connecting-ip missing; using untrusted proxy/session fallback");if(forwarded&&forwarded.length<=100)keys.push({scopeKey:"proxy-untrusted:"+forwarded,limit:8,windowSeconds:60});keys.push({scopeKey:"fallback-session-ua:"+stableSession+":"+userAgent,limit:10,windowSeconds:60})}keys.push({scopeKey:"session:"+stableSession,limit:15,windowSeconds:60});return keys} +async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} +async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds}),signal:AbortSignal.timeout(4000)});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} +async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"},signal:AbortSignal.timeout(4000)});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} type ChatbotRuntimeConfig={enabled:boolean;ai_rerank_enabled:boolean;lead_capture_enabled:boolean;comparison_enabled:boolean;budget_planner_enabled:boolean;max_candidates:number;max_results:number;max_output_tokens:number;temperature:number;maintenance_message:string}; const SAFE_FALLBACK_CONFIG:ChatbotRuntimeConfig={enabled:false,ai_rerank_enabled:false,lead_capture_enabled:false,comparison_enabled:false,budget_planner_enabled:false,max_candidates:10,max_results:1,max_output_tokens:200,temperature:0,maintenance_message:"Chatbot system maintenance par hai. Kripya thodi der baad koshish karein ya seedhe support par sampark karein."}; const DEFAULT_RUNTIME_CONFIG:ChatbotRuntimeConfig=SAFE_FALLBACK_CONFIG; @@ -31,14 +31,14 @@ function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,l function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.type,p.gender_type,p.gender,p.city,p.area,p.service_area,p.address,p.landmark,p.nearby_coaching,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return []; - const params=new URLSearchParams({select:"*",status:"eq.active",limit:"500",offset:"0"}); + const pageSize=250,maxRows=5000;const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),offset:"0"}); if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`); const orGroups:string[]=[]; if(maxBudget!==undefined&&Number.isFinite(maxBudget)&&maxBudget>0){ const budget=String(Math.floor(maxBudget)); orGroups.push(type==="hostel" ? `monthly_rent.lte.${budget},and(monthly_rent.is.null,monthly_fee.lte.${budget}),and(monthly_rent.is.null,monthly_fee.is.null,price.lte.${budget})` - : `monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget})`); + : type==="tiffin" ? `monthly_plan.lte.${budget},and(monthly_plan.is.null,monthly_fee.lte.${budget}),and(monthly_plan.is.null,monthly_fee.is.null,price.lte.${budget})` : `monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget})`); } const term=String(locationTerm||"").replace(/[^a-zA-Z0-9\u0900-\u097f -]/g," ").replace(/\s+/g," ").trim(); if(term)orGroups.push(["name","area","address","landmark","nearby_coaching","description"].map(col=>`${col}.ilike.*${term}*`).join(",")); @@ -48,9 +48,8 @@ async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:str } if(orGroups.length===1)params.set("or",`(${orGroups[0]})`); else if(orGroups.length>1)params.set("and",`(${orGroups.map(group=>`or(${group})`).join(",")})`); - const rows=await db(`/rest/v1/${table}?${params.toString()}`); - const page=Array.isArray(rows)?rows:[]; - // Current category tables contain fewer than 500 rows each; keep the Edge Function candidate set bounded. + const page:any[]=[];let offset=0;while(offset(`/rest/v1/${table}?${params.toString()}`);const batch=Array.isArray(rows)?rows:[];page.push(...batch);if(batch.length({...p,_type:type})).filter(p=>!ct||n(p.city||"").includes(n(ct))); } async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ @@ -58,11 +57,21 @@ async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationT const groups=await Promise.all(unique.map(type=>direct(type,ct,maxBudget,locationTerm,genderFilter))); return groups.flat(); } +function monthlyAmount(p:any,type:string){const value=Number(type==="hostel"?(p.monthly_rent??p.monthly_fee??p.price):(p.monthly_plan??p.monthly_fee??p.price));return Number.isFinite(value)&&value>0?value:undefined} +async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ + const hostelCap=Math.floor(totalBudget*0.70),tiffinCap=Math.floor(totalBudget*0.35); + const [hostels,tiffins]=await Promise.all([direct("hostel",cityName,hostelCap,undefined,genderFilter),direct("tiffin",cityName,tiffinCap)]); + const pricedHostels=hostels.map(p=>({p,cost:monthlyAmount(p,"hostel")})).filter(x=>x.cost!==undefined) as Array<{p:any;cost:number}>; + const pricedTiffins=tiffins.map(p=>({p,cost:monthlyAmount(p,"tiffin")})).filter(x=>x.cost!==undefined) as Array<{p:any;cost:number}>; + let best:{hostel:any;tiffin:any;hostelCost:number;tiffinCost:number;total:number;score:number}|undefined; + for(const h of pricedHostels)for(const t of pricedTiffins){const total=h.cost+t.cost;if(total>totalBudget)continue;const score=Math.abs(h.cost-totalBudget*0.65)+Math.abs(t.cost-totalBudget*0.35)+(totalBudget-total)*0.15;if(!best||score{const s=String(v??"").trim();return s&&s!=="0"&&s.replace(/\D/g,"").length>=7})||""),rating:num(p.rating),verified:p.verified===true,price,distanceKm:d===undefined?null:Number(d.toFixed(1)),distanceVerified:d!==undefined,distanceLabel:d===undefined?(p._nearbyAreaMatch?`${p.area||p.service_area||p.city||"Area"} area match · exact distance unavailable`:""):`~${d.toFixed(1)} km (GPS straight-line)`,nearbyMatch:p._nearbyVerified===true||p._nearbyAreaMatch===true,highlights:["wifi","ac_available","ac","cctv","food_available","mess_available","available_beds","room_types","room_sharing","gender_type","timing","timings","delivery_available","nearby_coaching"].map(k=>[k,p[k]]).filter(([,v])=>v!==null&&v!==undefined&&String(v).trim()!==""&&String(v).toLowerCase()!=="false").slice(0,4).map(([k,v])=>k+": "+String(v)),description:p.description&&String(p.description).trim().length<=280?String(p.description).trim():"",mapsUrl:p.google_maps_url||"",detailsUrl:p.slug?`property-details.html?type=${c}&slug=${encodeURIComponent(p.slug)}`:undefined}} -function rank(p:any,q:string,ct:string,near:string){const x=searchText(p),t=n(q);let s=0;const a=areaWanted(q);if(a&&x.includes(a))s+=25;if(ct&&n(p.city||"").includes(n(ct)))s+=30;for(const w of t.split(" ").filter(z=>z.length>2).slice(0,20))if(x.includes(w))s+=2;if(near){if(near==="allen"||near==="clc"){if(p._distanceKm!==undefined)s+=Math.max(0,20-p._distanceKm*5);if(x.includes(near))s+=10}else if(near==="vibrant"){if(x.includes("vibrant"))s+=20;if(x.includes("nawalgarh"))s+=8}else if(near==="matrix"||near==="gurukripa"){if(x.includes(near))s+=30;else s-=3}else if(x.includes(n(near)))s+=20}if(p.verified===true)s+=4;if(Number.isFinite(Number(p.rating)))s+=Math.min(5,Number(p.rating));return s} +function rank(p:any,q:string,ct:string,near:string){const x=searchText(p),t=n(q);let s=0;if(p._distanceKm!==undefined)s+=Math.max(0,25-p._distanceKm*5);const a=areaWanted(q);if(a&&x.includes(a))s+=25;if(ct&&n(p.city||"").includes(n(ct)))s+=30;for(const w of t.split(" ").filter(z=>z.length>2).slice(0,20))if(x.includes(w))s+=2;if(near){if(near==="allen"||near==="clc"){if(p._distanceKm!==undefined)s+=Math.max(0,20-p._distanceKm*5);if(x.includes(near))s+=10}else if(near==="vibrant"){if(x.includes("vibrant"))s+=20;if(x.includes("nawalgarh"))s+=8}else if(near==="matrix"||near==="gurukripa"){if(x.includes(near))s+=30;else s-=3}else if(x.includes(n(near)))s+=20}if(p.verified===true)s+=4;if(Number.isFinite(Number(p.rating)))s+=Math.min(5,Number(p.rating));return s} function isGreeting(s:string){return /^(hi|hello|hey|namaste|good morning|good afternoon|good evening|good night|gm|gn|hii|hlo|नमस्ते|सुप्रभात|शुभ प्रभात|शुभ संध्या)[!.\s]*$/i.test(s.trim())} function isAccountSupport(s:string){ const x=n(s); @@ -219,7 +228,7 @@ const enhancementTimeout=Math.min(8000,Math.max(0,deadlineAt===undefined?8000:de async function dbWrite(method:string,path:string,body:any,prefer="return=minimal"){ if(!U||!K)throw Error("database configuration missing"); - const r=await fetch(U+path,{method,headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json",Prefer:prefer},body:JSON.stringify(body)}); + const r=await fetch(U+path,{method,headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json",Prefer:prefer},body:JSON.stringify(body),signal:AbortSignal.timeout(4000)}); if(!r.ok)throw Error(`database write ${r.status}: ${(await r.text()).slice(0,200)}`); if(r.status===204)return null; return await r.json().catch(()=>null); @@ -338,6 +347,9 @@ Deno.serve(async(req:Request)=>{ if(isAccountSupport(msg))return await chatResponse({reply:accountSupportReply(msg),intent:"Account / Login Support",primaryTopic:"Account access",suggestedFollowUps:["I forgot my password","Google login is not working","Dashboard is not opening","Contact support"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); if(isContact(msg)||String(b?.action||"")==="contact_support")return await chatResponse({reply:"Ji bilkul 😊 StudentHubHelp team se contact ke liye:\n\n📧 Email: studenthubhelpp@gmail.com\n📞 Phone: +91 9929718264",intent:"Support / Contact",primaryTopic:"Contact StudentHubHelp",suggestedFollowUps:["Find a hostel","Find a library","Search by area","Back to search"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),aiCats=u.categories||[],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); + const clientCoords=coords(b?.clientCoords); + const genericNearbyIntent=/(?:\\bnear me\\b|\\bnearby\\b|\\bnearest\\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(n(msg))&&!u.landmark&&!u.area&&!u.locality&&!nearTerm(msg); + if(propertyMode&&genericNearbyIntent&&!clientCoords)return await chatResponse({reply:"Aapke aas-paas listings dikhane ke liye device location ki zaroorat hai. Kripya location access allow karein ya apna landmark/area batayein (jaise Piprali Road ya Allen ke paas).",intent:"Location Required",primaryTopic:"Nearby listing search",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by area","Search by city"],grounded:true,sessionId},{headers:H},sessionId,msg); if(action==="search_area"&&!city(msg)&&!nearTerm(msg))return await chatResponse({reply:"Bilkul 😊 Aap kis **area / locality** mein search karna chahte hain? Area ka naam bhejiye, main current requirement ke saath search refine kar dunga.",intent:"Area Refinement",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by city","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.needsClarification&&u.clarificationQuestion&&!u.category&&!u.categories.length&&!u.locality&&!u.area&&!u.landmark)return await chatResponse({reply:u.clarificationQuestion,intent:"Clarification",primaryTopic:"Requirement clarification",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a hostel","Find a library","Find a cafe","Find a tiffin service"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.intent==="property_reference"&&u.referenceIndex){ @@ -417,7 +429,19 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(isPropertyAdviceQuestion(msg)){const adviceCat=cat(msg)||cat(previous)||"hostel";return await chatResponse({reply:adviceReply(adviceCat),intent:"Property Selection Guidance",primaryTopic:label(adviceCat)+" selection guidance",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a property","Search by area","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg,requestDeadlineAt);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(!propertyMode){const ar=await aiGeneral(msg,history,cfg,requestDeadlineAt);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} + const comboCategories=[...new Set([...(c?[c]:[]),...aiCats,...nearCats,...(["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\\\bpg\\\\b":type==="tiffin"?"tiffin|mess":type==="library"?"library":type==="cafe"?"cafe":"bookstore","i").test(n(msg))))])]; + const budgetFromText=Number(u.budgetMax)||Number(msg.match(/(?:₹|rs\\.?|inr)?\\s*(\\d{4,6})/i)?.[1])||0; + if(cfg.budget_planner_enabled&&budgetFromText>0&&comboCategories.includes("hostel")&&comboCategories.includes("tiffin")){ + if(!ct)return await chatResponse({reply:"Hostel + tiffin ka exact monthly combo banane ke liye apni city bata dijiye.",intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Sikar mein ₹8000 budget","Kota mein ₹8000 budget","Jaipur mein ₹8000 budget"],grounded:true,sessionId},{headers:H},sessionId,msg); + const combo=await planComboBudget(ct,budgetFromText,u.gender||genderWanted(msg)); + if(!combo)return await chatResponse({reply:`${ct} mein ₹${budgetFromText.toLocaleString("en-IN")} ke andar verified monthly price ke saath hostel + tiffin ka complete combo abhi nahi mila. Aap budget badha sakte hain ya dono categories alag-alag search kar sakte hain.`,intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Hostel alag search karo","Tiffin alag search karo","Budget badhakar try karo"],grounded:true,sessionId},{headers:H},sessionId,msg); + const hostelCard=card(combo.hostel),tiffinCard=card(combo.tiffin),total=combo.total,savings=budgetFromText-total; + const budgetPlan={targetBudget:budgetFromText,city:ct,livingType:"pg_tiffin" as const,items:[{category:"rent" as const,label:`${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month`,amount:combo.hostelCost,note:[hostelCard.area,hostelCard.city].filter(Boolean).join(", ")},{category:"food" as const,label:`${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month`,amount:combo.tiffinCost,note:[tiffinCard.area,tiffinCard.city].filter(Boolean).join(", ")}],totalEstimated:total,savingsOrOverrun:savings,status:savings>300?"under_budget" as const:savings< -300?"over_budget" as const:"balanced" as const,savingsTips:["Prices are taken from active StudentHubHelp listings; confirm inclusions and availability with the provider before payment."],recommendedCombo:`${hostelCard.name} + ${tiffinCard.name}`}; + const reply=`Aapke ₹${budgetFromText.toLocaleString("en-IN")} monthly budget mein ${ct} ka best available combo:\\n\\n🏠 **Hostel:** ${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month\\n🍲 **Tiffin:** ${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month\\n\\n💰 **Total:** ₹${total.toLocaleString("en-IN")}/month\\n**Bachat:** ₹${savings.toLocaleString("en-IN")}/month\\n\\nYeh prices active listing records se li gayi hain; final inclusions aur availability provider se confirm karein.`; + return await chatResponse({reply,intent:"Combined Budget Plan",primaryTopic:"Hostel + Tiffin Combo Budget",budgetPlan,recommendedProperties:[hostelCard,tiffinCard],recommendations:[hostelCard,tiffinCard],suggestedFollowUps:["Hostel ki full details batao","Tiffin ki full details batao","Aur combo options dikhao"],grounded:true,sessionId,targetCity:ct,targetCategory:"hostel+tiffin"},{headers:H},sessionId,msg); + } let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined,u.area||u.locality||undefined,u.gender||genderWanted(msg)); + if(clientCoords)for(const p of rows){const d=distanceKm(clientCoords,p);if(d!==undefined)p._distanceKm=d} const wantedGender=u.gender||genderWanted(msg),wantedArea=u.area?(/nawalgarh/i.test(u.area)?"nawalgarh":/piprali/i.test(u.area)?"piprali":u.area.toLowerCase()):areaWanted(msg); const wantedLocality=n(u.locality||""); if(wantedGender)rows=rows.filter(p=>{const z=searchText(p);const g=n([p.gender_type,p.gender,p.hostel_type,p.type,p.name,p.title,p.description].filter(Boolean).join(" "));return wantedGender==="girls"?/girls|girl|female|women|ladki|ladkiyon|महिला|लड़क/i.test(g):/boys|boy|male|men|ladke|लड़के/i.test(g)}); From b36d1f63971ce877b49934c948b75e58821d0878 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:06:56 +0530 Subject: [PATCH 41/95] Correct combo planner async and regex escaping --- supabase/functions/studenthubhelp-chat/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 9b8d0bfcc..4195213af 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -58,7 +58,7 @@ async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationT return groups.flat(); } function monthlyAmount(p:any,type:string){const value=Number(type==="hostel"?(p.monthly_rent??p.monthly_fee??p.price):(p.monthly_plan??p.monthly_fee??p.price));return Number.isFinite(value)&&value>0?value:undefined} -async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ +async async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ const hostelCap=Math.floor(totalBudget*0.70),tiffinCap=Math.floor(totalBudget*0.35); const [hostels,tiffins]=await Promise.all([direct("hostel",cityName,hostelCap,undefined,genderFilter),direct("tiffin",cityName,tiffinCap)]); const pricedHostels=hostels.map(p=>({p,cost:monthlyAmount(p,"hostel")})).filter(x=>x.cost!==undefined) as Array<{p:any;cost:number}>; @@ -348,7 +348,7 @@ Deno.serve(async(req:Request)=>{ if(isContact(msg)||String(b?.action||"")==="contact_support")return await chatResponse({reply:"Ji bilkul 😊 StudentHubHelp team se contact ke liye:\n\n📧 Email: studenthubhelpp@gmail.com\n📞 Phone: +91 9929718264",intent:"Support / Contact",primaryTopic:"Contact StudentHubHelp",suggestedFollowUps:["Find a hostel","Find a library","Search by area","Back to search"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),aiCats=u.categories||[],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); const clientCoords=coords(b?.clientCoords); - const genericNearbyIntent=/(?:\\bnear me\\b|\\bnearby\\b|\\bnearest\\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(n(msg))&&!u.landmark&&!u.area&&!u.locality&&!nearTerm(msg); + const genericNearbyIntent=/(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(n(msg))&&!u.landmark&&!u.area&&!u.locality&&!nearTerm(msg); if(propertyMode&&genericNearbyIntent&&!clientCoords)return await chatResponse({reply:"Aapke aas-paas listings dikhane ke liye device location ki zaroorat hai. Kripya location access allow karein ya apna landmark/area batayein (jaise Piprali Road ya Allen ke paas).",intent:"Location Required",primaryTopic:"Nearby listing search",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by area","Search by city"],grounded:true,sessionId},{headers:H},sessionId,msg); if(action==="search_area"&&!city(msg)&&!nearTerm(msg))return await chatResponse({reply:"Bilkul 😊 Aap kis **area / locality** mein search karna chahte hain? Area ka naam bhejiye, main current requirement ke saath search refine kar dunga.",intent:"Area Refinement",primaryTopic:c?label(c)+" discovery":"Student Services Discovery",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by city","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg); if(u.needsClarification&&u.clarificationQuestion&&!u.category&&!u.categories.length&&!u.locality&&!u.area&&!u.landmark)return await chatResponse({reply:u.clarificationQuestion,intent:"Clarification",primaryTopic:"Requirement clarification",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a hostel","Find a library","Find a cafe","Find a tiffin service"],grounded:true,sessionId},{headers:H},sessionId,msg); @@ -429,7 +429,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(isPropertyAdviceQuestion(msg)){const adviceCat=cat(msg)||cat(previous)||"hostel";return await chatResponse({reply:adviceReply(adviceCat),intent:"Property Selection Guidance",primaryTopic:label(adviceCat)+" selection guidance",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Find a property","Search by area","Show more options","Contact support"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg,requestDeadlineAt);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(!propertyMode){const ar=await aiGeneral(msg,history,cfg,requestDeadlineAt);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} - const comboCategories=[...new Set([...(c?[c]:[]),...aiCats,...nearCats,...(["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\\\bpg\\\\b":type==="tiffin"?"tiffin|mess":type==="library"?"library":type==="cafe"?"cafe":"bookstore","i").test(n(msg))))])]; + const comboCategories=[...new Set([...(c?[c]:[]),...aiCats,...nearCats,...(["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\bpg\\b":type==="tiffin"?"tiffin|mess":type==="library"?"library":type==="cafe"?"cafe":"bookstore","i").test(n(msg))))])]; const budgetFromText=Number(u.budgetMax)||Number(msg.match(/(?:₹|rs\\.?|inr)?\\s*(\\d{4,6})/i)?.[1])||0; if(cfg.budget_planner_enabled&&budgetFromText>0&&comboCategories.includes("hostel")&&comboCategories.includes("tiffin")){ if(!ct)return await chatResponse({reply:"Hostel + tiffin ka exact monthly combo banane ke liye apni city bata dijiye.",intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Sikar mein ₹8000 budget","Kota mein ₹8000 budget","Jaipur mein ₹8000 budget"],grounded:true,sessionId},{headers:H},sessionId,msg); From e51154829d971e946ee045e1eca69cd9e78336dc Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:07:12 +0530 Subject: [PATCH 42/95] Fix combo budget parsing and response formatting --- supabase/functions/studenthubhelp-chat/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 4195213af..2dd4884c8 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -430,14 +430,14 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg,requestDeadlineAt);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(!propertyMode){const ar=await aiGeneral(msg,history,cfg,requestDeadlineAt);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} const comboCategories=[...new Set([...(c?[c]:[]),...aiCats,...nearCats,...(["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\bpg\\b":type==="tiffin"?"tiffin|mess":type==="library"?"library":type==="cafe"?"cafe":"bookstore","i").test(n(msg))))])]; - const budgetFromText=Number(u.budgetMax)||Number(msg.match(/(?:₹|rs\\.?|inr)?\\s*(\\d{4,6})/i)?.[1])||0; + const budgetFromText=Number(u.budgetMax)||Number(msg.match(/(?:₹|rs\.?|inr)?\s*(\d{4,6})/i)?.[1])||0; if(cfg.budget_planner_enabled&&budgetFromText>0&&comboCategories.includes("hostel")&&comboCategories.includes("tiffin")){ if(!ct)return await chatResponse({reply:"Hostel + tiffin ka exact monthly combo banane ke liye apni city bata dijiye.",intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Sikar mein ₹8000 budget","Kota mein ₹8000 budget","Jaipur mein ₹8000 budget"],grounded:true,sessionId},{headers:H},sessionId,msg); const combo=await planComboBudget(ct,budgetFromText,u.gender||genderWanted(msg)); if(!combo)return await chatResponse({reply:`${ct} mein ₹${budgetFromText.toLocaleString("en-IN")} ke andar verified monthly price ke saath hostel + tiffin ka complete combo abhi nahi mila. Aap budget badha sakte hain ya dono categories alag-alag search kar sakte hain.`,intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Hostel alag search karo","Tiffin alag search karo","Budget badhakar try karo"],grounded:true,sessionId},{headers:H},sessionId,msg); const hostelCard=card(combo.hostel),tiffinCard=card(combo.tiffin),total=combo.total,savings=budgetFromText-total; const budgetPlan={targetBudget:budgetFromText,city:ct,livingType:"pg_tiffin" as const,items:[{category:"rent" as const,label:`${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month`,amount:combo.hostelCost,note:[hostelCard.area,hostelCard.city].filter(Boolean).join(", ")},{category:"food" as const,label:`${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month`,amount:combo.tiffinCost,note:[tiffinCard.area,tiffinCard.city].filter(Boolean).join(", ")}],totalEstimated:total,savingsOrOverrun:savings,status:savings>300?"under_budget" as const:savings< -300?"over_budget" as const:"balanced" as const,savingsTips:["Prices are taken from active StudentHubHelp listings; confirm inclusions and availability with the provider before payment."],recommendedCombo:`${hostelCard.name} + ${tiffinCard.name}`}; - const reply=`Aapke ₹${budgetFromText.toLocaleString("en-IN")} monthly budget mein ${ct} ka best available combo:\\n\\n🏠 **Hostel:** ${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month\\n🍲 **Tiffin:** ${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month\\n\\n💰 **Total:** ₹${total.toLocaleString("en-IN")}/month\\n**Bachat:** ₹${savings.toLocaleString("en-IN")}/month\\n\\nYeh prices active listing records se li gayi hain; final inclusions aur availability provider se confirm karein.`; + const reply=\`Aapke ₹\${budgetFromText.toLocaleString("en-IN")} monthly budget mein \${ct} ka best available combo:\n\n🏠 **Hostel:** \${hostelCard.name} — ₹\${combo.hostelCost.toLocaleString("en-IN")}/month\n🍲 **Tiffin:** \${tiffinCard.name} — ₹\${combo.tiffinCost.toLocaleString("en-IN")}/month\n\n💰 **Total:** ₹\${total.toLocaleString("en-IN")}/month\n**Bachat:** ₹\${savings.toLocaleString("en-IN")}/month\n\nYeh prices active listing records se li gayi hain; final inclusions aur availability provider se confirm karein.\`; return await chatResponse({reply,intent:"Combined Budget Plan",primaryTopic:"Hostel + Tiffin Combo Budget",budgetPlan,recommendedProperties:[hostelCard,tiffinCard],recommendations:[hostelCard,tiffinCard],suggestedFollowUps:["Hostel ki full details batao","Tiffin ki full details batao","Aur combo options dikhao"],grounded:true,sessionId,targetCity:ct,targetCategory:"hostel+tiffin"},{headers:H},sessionId,msg); } let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined,u.area||u.locality||undefined,u.gender||genderWanted(msg)); From 181834bf602dd4e6b5a44baad9637207c30d7203 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:07:25 +0530 Subject: [PATCH 43/95] Add opt-in GPS location and graceful voice fallback --- ai-chatbot/src/components/ChatWidget.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ai-chatbot/src/components/ChatWidget.tsx b/ai-chatbot/src/components/ChatWidget.tsx index 82f0a8613..0091ed0ee 100644 --- a/ai-chatbot/src/components/ChatWidget.tsx +++ b/ai-chatbot/src/components/ChatWidget.tsx @@ -54,6 +54,23 @@ const QUICK_TOPICS = [ { label: 'Search by Area', query: 'Mere area ke active listings dikhao', topic: 'Area Discovery' }, ]; +async function getUserDeviceLocation(): Promise<{ lat: number; lng: number } | null> { + if (typeof navigator === 'undefined' || !navigator.geolocation) return null; + return new Promise((resolve) => { + navigator.geolocation.getCurrentPosition( + (position) => resolve({ lat: position.coords.latitude, lng: position.coords.longitude }), + () => resolve(null), + { timeout: 5000, maximumAge: 60000, enableHighAccuracy: false } + ); + }); +} + +function asksForNearbyLocation(text: string): boolean { + return /(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(text) + && !/(allen|clc|vibrant|matrix|gurukripa|piprali road|piprali rd|nawalgarh road)/i.test(text); +} + + export const ChatWidget: React.FC = ({ onDiagnosisUpdate, onLeadCaptured, @@ -99,7 +116,10 @@ export const ChatWidget: React.FC = ({ }, []); const toggleSpeechRecognition = () => { - if (!recognitionRef.current) return; + if (!recognitionRef.current) { + setMessages(prev => [...prev, { id: `voice-unsupported-${Date.now()}`, sender: 'bot', text: 'Aapke browser mein voice input support nahi hai. Kripya apna sawaal type karein.', timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); + return; + } if (isListening) { recognitionRef.current.stop(); setIsListening(false); } else { try { recognitionRef.current.start(); setIsListening(true); } catch { setIsListening(false); } } }; const speakText = (text: string) => { @@ -124,6 +144,7 @@ export const ChatWidget: React.FC = ({ }) || lastPropertyMessage?.recommendedProperties?.[0] : undefined; if (!SUPABASE_ANON_KEY) throw new Error('Missing VITE_SUPABASE_ANON_KEY. Add the public Supabase anon/publishable key to the Vite build environment.'); + const clientCoords = asksForNearbyLocation(text) ? await getUserDeviceLocation() : null; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 15000); let res: Response; try { res = await fetch(CHAT_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', apikey: SUPABASE_ANON_KEY, Authorization: `Bearer ${SUPABASE_ANON_KEY}` }, body: JSON.stringify({ @@ -131,6 +152,7 @@ export const ChatWidget: React.FC = ({ history, preferredTopic: topicToPrioritize, sessionId, + clientCoords, focusPropertyId: focusedProperty?.id ? String(focusedProperty.id) : undefined, focusPropertyType: focusedProperty?.type ? String(focusedProperty.type) : undefined }), signal: controller.signal }); From 5b4b871bc38ffec427b92ecab3f2caf5bbddce48 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:07:51 +0530 Subject: [PATCH 44/95] Add chatbot search regression test suite --- ai-chatbot/tests/chatbot-regression.mjs | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 ai-chatbot/tests/chatbot-regression.mjs diff --git a/ai-chatbot/tests/chatbot-regression.mjs b/ai-chatbot/tests/chatbot-regression.mjs new file mode 100644 index 000000000..824ead1c3 --- /dev/null +++ b/ai-chatbot/tests/chatbot-regression.mjs @@ -0,0 +1,81 @@ +const endpoint = process.env.CHATBOT_TEST_URL || 'http://localhost:54321/functions/v1/studenthubhelp-chat'; +const apiKey = process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || ''; + +if (!apiKey) { + console.error('Set SUPABASE_ANON_KEY (local Supabase anon key) before running regression tests.'); + process.exit(2); +} + +const cases = [ + { query: 'Sikar mein Allen ke paas boys hostel', city: 'Sikar', category: 'hostel', forbiddenCity: 'Kota' }, + { query: 'Kota Talwandi mein budget tiffin', city: 'Kota', category: 'tiffin', forbiddenCategory: 'hostel' }, + { query: 'Jhunjhunu me cafe', city: 'Jhunjhunu', category: 'cafe' }, + { query: 'Piprali Road hostel under 5000', city: 'Sikar', category: 'hostel', maxBudget: 5000 }, +]; + +function numericPrice(value) { + const match = String(value ?? '').replace(/,/g, '').match(/\d+(?:\.\d+)?/); + return match ? Number(match[0]) : null; +} + +async function runCase(testCase, index) { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: apiKey, + Authorization: 'Bearer ' + apiKey, + }, + body: JSON.stringify({ + message: testCase.query, + sessionId: 'regression-' + Date.now() + '-' + index, + }), + }); + + let data; + try { + data = await response.json(); + } catch { + throw new Error('HTTP ' + response.status + ': response was not valid JSON'); + } + if (!response.ok) throw new Error('HTTP ' + response.status + ': ' + (data?.error || data?.reply || 'request failed')); + + const listings = data.recommendedProperties || data.recommendations || []; + const city = String(data.targetCity || data.aiEntities?.city || '').toLowerCase(); + const category = String(data.targetCategory || data.aiEntities?.category || '').toLowerCase(); + const failures = []; + + if (!city.includes(testCase.city.toLowerCase())) failures.push('expected target city ' + testCase.city + ', received ' + (city || 'none')); + if (testCase.category && category && !category.includes(testCase.category)) failures.push('expected category ' + testCase.category + ', received ' + category); + if (!Array.isArray(listings) || listings.length === 0) failures.push('no live listing returned'); + for (const listing of listings) { + const listingCity = String(listing.city || '').toLowerCase(); + const listingCategory = String(listing.type || listing.category || '').toLowerCase(); + if (listingCity && listingCity !== testCase.city.toLowerCase()) failures.push('cross-city listing leaked: ' + listing.city); + if (testCase.forbiddenCity && listingCity.includes(testCase.forbiddenCity.toLowerCase())) failures.push('forbidden city leaked: ' + listing.city); + if (testCase.forbiddenCategory && listingCategory.includes(testCase.forbiddenCategory)) failures.push('forbidden category leaked: ' + listingCategory); + if (testCase.category && listingCategory && !listingCategory.includes(testCase.category)) failures.push('wrong category listing: ' + listingCategory); + if (testCase.maxBudget) { + const price = numericPrice(listing.price); + if (price !== null && price > testCase.maxBudget) failures.push('budget exceeded: ' + listing.price); + if (price === null) failures.push('listing price missing; cannot verify max budget: ' + (listing.name || listing.id)); + } + } + + if (failures.length) throw new Error(failures.join('; ')); + return testCase.query + ' (' + listings.length + ' listings)'; +} + +console.log('StudentHubHelp chatbot regression suite: ' + endpoint); +let failed = 0; +for (const [index, testCase] of cases.entries()) { + try { + const detail = await runCase(testCase, index); + console.log('PASS: ' + detail); + } catch (error) { + failed += 1; + console.error('FAIL: ' + testCase.query + '\n ' + (error instanceof Error ? error.message : String(error))); + } +} +console.log('\nResult: ' + (cases.length - failed) + '/' + cases.length + ' passed'); +if (failed) process.exit(1); From 9662fb9468539e942a4bafa78dc6b3a8e57ebcdc Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:07:56 +0530 Subject: [PATCH 45/95] Pin production chatbot API to Supabase Edge Function --- ai-chatbot/.env.production | 1 + 1 file changed, 1 insertion(+) create mode 100644 ai-chatbot/.env.production diff --git a/ai-chatbot/.env.production b/ai-chatbot/.env.production new file mode 100644 index 000000000..f0f4bcbfb --- /dev/null +++ b/ai-chatbot/.env.production @@ -0,0 +1 @@ +VITE_CHAT_API_URL=https://idurlccrarznnnqixxsd.supabase.co/functions/v1/studenthubhelp-chat From fc93545c410e925e7f25763ab824d4276985b81b Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:08:07 +0530 Subject: [PATCH 46/95] Add dependency audit report and regression script syntax check --- .github/workflows/chatbot-pr-check.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index 58919b74a..7724878ab 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -46,6 +46,28 @@ jobs: cache-dependency-path: ai-chatbot/package-lock.json - name: Install dependencies run: npm install + - name: Audit dependency tree (non-blocking report) + run: | + npm audit --json > /tmp/studenthubhelp-npm-audit.json || true + node - <<'NODE' + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync('/tmp/studenthubhelp-npm-audit.json', 'utf8')); + const vulnerabilities = report.vulnerabilities || {}; + const entries = Object.entries(vulnerabilities); + console.log('npm audit summary:', JSON.stringify(report.metadata?.vulnerabilities || {})); + for (const [name, item] of entries) { + console.log(JSON.stringify({ + name, + severity: item.severity, + range: item.range, + direct: item.isDirect, + fixAvailable: item.fixAvailable, + via: item.via + })); + } + NODE + - name: Validate regression test script syntax + run: node --check tests/chatbot-regression.mjs - name: Type-check React chatbot component run: npx tsc --noEmit --skipLibCheck --jsx react-jsx --target ES2020 --module ESNext --moduleResolution bundler --allowImportingTsExtensions --types vite/client src/components/ChatWidget.tsx - name: Restore Vite source entry for validation From 80dba35c8756eaf968de1d404f085f15822c3e6e Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:08:55 +0530 Subject: [PATCH 47/95] Enforce exact city boundary in combined budget results --- supabase/functions/studenthubhelp-chat/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 2dd4884c8..ff2c4bf9a 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -60,7 +60,8 @@ async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationT function monthlyAmount(p:any,type:string){const value=Number(type==="hostel"?(p.monthly_rent??p.monthly_fee??p.price):(p.monthly_plan??p.monthly_fee??p.price));return Number.isFinite(value)&&value>0?value:undefined} async async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ const hostelCap=Math.floor(totalBudget*0.70),tiffinCap=Math.floor(totalBudget*0.35); - const [hostels,tiffins]=await Promise.all([direct("hostel",cityName,hostelCap,undefined,genderFilter),direct("tiffin",cityName,tiffinCap)]); + const [hostelRows,tiffinRows]=await Promise.all([direct("hostel",cityName,hostelCap,undefined,genderFilter),direct("tiffin",cityName,tiffinCap)]); + const hostels=hostelRows.filter(p=>n(p.city||"")===n(cityName));const tiffins=tiffinRows.filter(p=>n(p.city||"")===n(cityName)); const pricedHostels=hostels.map(p=>({p,cost:monthlyAmount(p,"hostel")})).filter(x=>x.cost!==undefined) as Array<{p:any;cost:number}>; const pricedTiffins=tiffins.map(p=>({p,cost:monthlyAmount(p,"tiffin")})).filter(x=>x.cost!==undefined) as Array<{p:any;cost:number}>; let best:{hostel:any;tiffin:any;hostelCost:number;tiffinCost:number;total:number;score:number}|undefined; From 7e4216764f715e2b4080819f944244db438e1e4b Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:09:05 +0530 Subject: [PATCH 48/95] Strengthen city/category regression checks and GPS fallback test --- ai-chatbot/tests/chatbot-regression.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ai-chatbot/tests/chatbot-regression.mjs b/ai-chatbot/tests/chatbot-regression.mjs index 824ead1c3..e44f926bc 100644 --- a/ai-chatbot/tests/chatbot-regression.mjs +++ b/ai-chatbot/tests/chatbot-regression.mjs @@ -11,6 +11,7 @@ const cases = [ { query: 'Kota Talwandi mein budget tiffin', city: 'Kota', category: 'tiffin', forbiddenCategory: 'hostel' }, { query: 'Jhunjhunu me cafe', city: 'Jhunjhunu', category: 'cafe' }, { query: 'Piprali Road hostel under 5000', city: 'Sikar', category: 'hostel', maxBudget: 5000 }, + { query: 'Mere paas hostel dikhao', expectLocationPrompt: true }, ]; function numericPrice(value) { @@ -44,13 +45,19 @@ async function runCase(testCase, index) { const city = String(data.targetCity || data.aiEntities?.city || '').toLowerCase(); const category = String(data.targetCategory || data.aiEntities?.category || '').toLowerCase(); const failures = []; + if (testCase.expectLocationPrompt) { + if (data.intent !== 'Location Required' || listings.length !== 0 || !String(data.reply || data.message || '').toLowerCase().includes('location')) failures.push('nearby query without GPS did not request device location/area'); + if (failures.length) throw new Error(failures.join('; ')); + return testCase.query + ' (GPS prompt returned)'; + } if (!city.includes(testCase.city.toLowerCase())) failures.push('expected target city ' + testCase.city + ', received ' + (city || 'none')); - if (testCase.category && category && !category.includes(testCase.category)) failures.push('expected category ' + testCase.category + ', received ' + category); + if (testCase.category && !category.includes(testCase.category)) failures.push('expected category ' + testCase.category + ', received ' + (category || 'none')); if (!Array.isArray(listings) || listings.length === 0) failures.push('no live listing returned'); for (const listing of listings) { const listingCity = String(listing.city || '').toLowerCase(); const listingCategory = String(listing.type || listing.category || '').toLowerCase(); + if (!listingCity) failures.push('listing city missing: ' + (listing.name || listing.id)); if (listingCity && listingCity !== testCase.city.toLowerCase()) failures.push('cross-city listing leaked: ' + listing.city); if (testCase.forbiddenCity && listingCity.includes(testCase.forbiddenCity.toLowerCase())) failures.push('forbidden city leaked: ' + listing.city); if (testCase.forbiddenCategory && listingCategory.includes(testCase.forbiddenCategory)) failures.push('forbidden category leaked: ' + listingCategory); From db068a44516329bf008e44a813bcfb131cd2db26 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:10:00 +0530 Subject: [PATCH 49/95] Add Jhunjhunu/Udaipurwati city parsing and tiffin monthly-plan filtering --- supabase/functions/studenthubhelp-chat/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index ff2c4bf9a..714933277 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -20,7 +20,7 @@ const DEFAULT_RUNTIME_CONFIG:ChatbotRuntimeConfig=SAFE_FALLBACK_CONFIG; let runtimeConfigCache:{value:ChatbotRuntimeConfig;expiresAt:number}|null=null; async function getRuntimeConfig():Promise{if(runtimeConfigCache&&runtimeConfigCache.expiresAt>Date.now())return runtimeConfigCache.value;try{const rows=await db("/rest/v1/chatbot_admin_config?select=*&id=eq.1&limit=1");const row=rows?.[0];if(!row){console.error("[CRITICAL] chatbot_admin_config row id=1 is missing; disabling chatbot");runtimeConfigCache={value:SAFE_FALLBACK_CONFIG,expiresAt:Date.now()+2000};return SAFE_FALLBACK_CONFIG}const value:ChatbotRuntimeConfig={enabled:row.enabled===true,ai_rerank_enabled:row.ai_rerank_enabled===true,lead_capture_enabled:row.lead_capture_enabled===true,comparison_enabled:row.comparison_enabled===true,budget_planner_enabled:row.budget_planner_enabled===true,max_candidates:Math.max(10,Math.min(100,Number(row.max_candidates)||40)),max_results:Math.max(1,Math.min(20,Number(row.max_results)||8)),max_output_tokens:Math.max(200,Math.min(3000,Number(row.max_output_tokens)||900)),temperature:Math.max(0,Math.min(1,Number.isFinite(Number(row.temperature))?Number(row.temperature):0.2)),maintenance_message:String(row.maintenance_message||"").slice(0,500)};runtimeConfigCache={value,expiresAt:Date.now()+2000};return value}catch(e){console.error("[CRITICAL] chatbot admin config fetch failed; disabling chatbot",e);runtimeConfigCache={value:SAFE_FALLBACK_CONFIG,expiresAt:Date.now()+2000};return SAFE_FALLBACK_CONFIG}} const LANDMARKS=[{key:"allen",label:"Allen Career Institute Sikar",aliases:["allen","allen sikar","allen coaching","allen career","allen sanskar","allen piprali"],city:"Sikar",lat:27.62624,lng:75.17721},{key:"clc",label:"CLC Sikar",aliases:["clc","clc sikar"],city:"Sikar",lat:27.6206291,lng:75.1616672},{key:"vibrant",label:"Vibrant Academy Sikar",aliases:["vibrant","vibrant coaching","vibrant academy","vibrant academy ssc","vibrant ssc"],city:"Sikar"},{key:"matrix",label:"Matrix Coaching Sikar",aliases:["matrix","matrix academy","matrix coaching","matrix sikar"],city:"Sikar"},{key:"gurukripa",label:"Gurukripa Coaching Sikar",aliases:["gurukripa","guru kripa","gurukripa coaching","gurukripa sikar"],city:"Sikar"}]; -function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} +function city(s:string){const x=n(s),m:[string,string][]=[["sikar","Sikar"],["piprali","Sikar"],["पिपराली","Sikar"],["सिकर","Sikar"],["udaipurwati","Udaipurwati"],["उदयपुरवाटी","Udaipurwati"],["jhunjhunu","Jhunjhunu"],["झुंझुनूं","Jhunjhunu"],["झुंझुनू","Jhunjhunu"],["kota","Kota"],["talwandi","Kota"],["jaipur","Jaipur"],["ajmer","Ajmer"],["bikaner","Bikaner"],["jodhpur","Jodhpur"],["udaipur","Udaipur"],["alwar","Alwar"],["delhi","Delhi"],["ahmedabad","Ahmedabad"],["lucknow","Lucknow"],["hyderabad","Hyderabad"],["pune","Pune"]];return m.find(([a])=>x.includes(n(a)))?.[1]||""} function findLandmark(s:string){const x=n(normalizeSearchTerms(s));for(const l of LANDMARKS)if(l.aliases.some(a=>x.includes(n(a))))return l;return undefined} function nearTerm(s:string){const x=n(normalizeSearchTerms(s)),lm=findLandmark(x);if(lm)return lm.key;const known=["vibrant coaching","vibrant career institute","nawalgarh road","nawalgarh rd","nawalgarh","clc","allen","gurukripa","matrix","matrix academy","prayas eduhub","banco career academy","inspector ssc academy"];const hit=known.find(k=>x.includes(n(k)));if(hit)return hit;const m=x.match(/(?:near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)(?:\s+me)?\s+([a-z0-9\u0900-\u097f][a-z0-9\u0900-\u097f ]{1,50}?)(?=\s+(?:chahiye|dikhao|dikhado|hai|ho|rehna|rahna|book|konsa|kaunsa|aur|bhi)\b|$)/i);if(m)return m[1].trim();if(/(?:piprali road|piprali rd|पिपराली रोड)/i.test(x))return "piprali road";return ""} function cat(s:string){const x=n(s),hits:[string,number][]=[];const defs:[string,RegExp][]=[["hostel",/hostel|pg|room|stay|accommodation|girls|boys|female|male|rehne|rehna|रहने|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food|meal|jain food|टिफिन|मेस|खाना/],["library",/library|reading|seat|लाइब्रेरी/],["cafe",/cafe|coffee|restaurant|hangout|कैफे/],["bookstore",/bookstore|books|stationery|bookshop|किताब|बुकस्टोर/]];for(const [type,re] of defs)for(const m of x.matchAll(new RegExp(re.source,"gi")))hits.push([type,m.index??-1]);const near=new Set(hits.filter(([,pos])=>/(near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)/i.test(x.slice(Math.max(0,pos-50),pos))).map(([type])=>type));return hits.find(([type])=>!near.has(type))?.[0]||hits[0]?.[0]} @@ -146,6 +146,8 @@ function normalizeIntent(x:any){ function deterministicLocation(s:string):StrictLocation{ const x=n(s); if(/\b(talwandi|tilwandi|तालवंडी|तलवंडी)\b/i.test(x))return{city:"Kota",locality:"Talwandi"}; + if(/\\b(udaipurwati|उदयपुरवाटी)\\b/i.test(x))return{city:"Udaipurwati"}; + if(/\\b(jhunjhunu|झुंझुनूं|झुंझुनू)\\b/i.test(x))return{city:"Jhunjhunu"}; if(/nawalgarh road|nawalgarh rd|nawalgarh|नवलगढ़ रोड|नवलगढ रोड/i.test(x))return{city:"Sikar",area:"Nawalgarh Road"}; if(/piprali road|piprali rd|piprali|पिपराली रोड|पिपराली/i.test(x))return{city:"Sikar",area:"Piprali Road"}; const lm=findLandmark(x);if(lm)return{city:lm.city,landmark:lm.key};return{}; @@ -199,7 +201,7 @@ function mergeIntent(ai:any,msg:string,history:any[]){ if(o.categories.length)o.category=o.categories[0]; return o; } -function budgetValue(p:any){return num(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price)} +function budgetValue(p:any){return num(p.monthly_rent??p.monthly_plan??p.monthly_fee??p.monthly_charge??p.price)} function matchesFacility(p:any,wanted:string[]){if(!wanted.length)return true;const z=searchText(p);return wanted.every(f=>z.includes(n(f))||Object.entries(p).some(([k,v])=>n(k).includes(n(f))&&(v===true||String(v).toLowerCase()==="true"||n(v).includes(n(f)))))} async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string,cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){if(!GEMINI||!cfg.ai_rerank_enabled)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. From c064b3b1428e1f71b06b1ab5b77806afa880dabc Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:10:10 +0530 Subject: [PATCH 50/95] Correct Unicode city-boundary regexes --- supabase/functions/studenthubhelp-chat/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 714933277..b594c4b3d 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -146,8 +146,8 @@ function normalizeIntent(x:any){ function deterministicLocation(s:string):StrictLocation{ const x=n(s); if(/\b(talwandi|tilwandi|तालवंडी|तलवंडी)\b/i.test(x))return{city:"Kota",locality:"Talwandi"}; - if(/\\b(udaipurwati|उदयपुरवाटी)\\b/i.test(x))return{city:"Udaipurwati"}; - if(/\\b(jhunjhunu|झुंझुनूं|झुंझुनू)\\b/i.test(x))return{city:"Jhunjhunu"}; + if(/\b(udaipurwati|उदयपुरवाटी)\b/i.test(x))return{city:"Udaipurwati"}; + if(/\b(jhunjhunu|झुंझुनूं|झुंझुनू)\b/i.test(x))return{city:"Jhunjhunu"}; if(/nawalgarh road|nawalgarh rd|nawalgarh|नवलगढ़ रोड|नवलगढ रोड/i.test(x))return{city:"Sikar",area:"Nawalgarh Road"}; if(/piprali road|piprali rd|piprali|पिपराली रोड|पिपराली/i.test(x))return{city:"Sikar",area:"Piprali Road"}; const lm=findLandmark(x);if(lm)return{city:lm.city,landmark:lm.key};return{}; From 731ebd5ab196e9a705735db600b8159af0edcf26 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:10:37 +0530 Subject: [PATCH 51/95] Allow session header in chatbot CORS policy --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index b594c4b3d..872eb658a 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -6,7 +6,7 @@ const U=(Deno.env.get("SUPABASE_URL")||"").replace(/\/$/,""); const K=Deno.env.get("SUPABASE_SECRET_KEY")||Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")||""; const GEMINI=Deno.env.get("GEMINI_API_KEY")||""; const MODEL="gemini-3.8-flash"; -const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"authorization,x-client-info,apikey,content-type","Access-Control-Allow-Methods":"POST,OPTIONS","Content-Type":"application/json"}; +const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"authorization,x-client-info,apikey,content-type,x-session-id","Access-Control-Allow-Methods":"POST,OPTIONS","Content-Type":"application/json"}; const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; From 5996c6be267b0d3f223f4e54175adb130ab8ab06 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:10:40 +0530 Subject: [PATCH 52/95] Make unsupported voice input explain fallback on click --- ai-chatbot/src/components/ChatWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-chatbot/src/components/ChatWidget.tsx b/ai-chatbot/src/components/ChatWidget.tsx index 0091ed0ee..3e685689b 100644 --- a/ai-chatbot/src/components/ChatWidget.tsx +++ b/ai-chatbot/src/components/ChatWidget.tsx @@ -387,7 +387,7 @@ export const ChatWidget: React.FC = ({
SH
StudentHubHelp AI
Live listing assistant
Live Supabase data · Smart assistant
{QUICK_TOPICS.map(topic=>)}
{recentLeadAlert&&
{recentLeadAlert}
}{messages.map(m=>
{m.sender==='user'?:}{m.sender==='user'?'You':'StudentHubHelp AI'}{m.timestamp}
{m.text}
{m.diagnosis?.primaryTopic&&m.sender==='bot'&&
Topic: {m.diagnosis.primaryTopic}
}{m.recommendedProperties?.length>0&&
{m.recommendedProperties.map(renderPropertyCard)}
}{m.budgetPlan&&
}{m.comparisonMatrix&&
}{m.diagnosis?.suggestedFollowUps?.length>0&&m.sender==='bot'&&
{m.diagnosis.suggestedFollowUps.slice(0,4).map((f:string)=>)}
}
)}{isLoading&&
AI is checking live listings…
}{retryText&&!isLoading&&
}
-
setInputText(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type any topic (Hostel, Tiffin, Library, Rates)..." className="min-w-0 flex-1 h-11 rounded-xl border border-slate-200 px-2.5 text-sm outline-none focus:border-amber-400"/>
Live listing support
+
setInputText(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type any topic (Hostel, Tiffin, Library, Rates)..." className="min-w-0 flex-1 h-11 rounded-xl border border-slate-200 px-2.5 text-sm outline-none focus:border-amber-400"/>
Live listing support
}
; }; From 6ce05732cde141777f3f3d42b18775490afa6662 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:11:02 +0530 Subject: [PATCH 53/95] Fix combined budget planner declaration syntax --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 872eb658a..b61f41281 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -58,7 +58,7 @@ async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationT return groups.flat(); } function monthlyAmount(p:any,type:string){const value=Number(type==="hostel"?(p.monthly_rent??p.monthly_fee??p.price):(p.monthly_plan??p.monthly_fee??p.price));return Number.isFinite(value)&&value>0?value:undefined} -async async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ +async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ const hostelCap=Math.floor(totalBudget*0.70),tiffinCap=Math.floor(totalBudget*0.35); const [hostelRows,tiffinRows]=await Promise.all([direct("hostel",cityName,hostelCap,undefined,genderFilter),direct("tiffin",cityName,tiffinCap)]); const hostels=hostelRows.filter(p=>n(p.city||"")===n(cityName));const tiffins=tiffinRows.filter(p=>n(p.city||"")===n(cityName)); From 6eb8fbe17bca99afdfd3f6028d48b5fce09ae5ae Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:11:34 +0530 Subject: [PATCH 54/95] Pin patched Vite and vulnerable transitive dependency overrides --- ai-chatbot/package.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ai-chatbot/package.json b/ai-chatbot/package.json index 08eb5ef1d..6b7e01ae6 100644 --- a/ai-chatbot/package.json +++ b/ai-chatbot/package.json @@ -18,7 +18,7 @@ "lucide-react": "^0.546.0", "react": "^19.0.1", "react-dom": "^19.0.1", - "vite": "^6.2.3", + "vite": "^6.4.4", "express": "^4.21.2", "dotenv": "^17.2.3", "motion": "^12.23.24" @@ -30,7 +30,13 @@ "tailwindcss": "^4.1.14", "tsx": "^4.21.0", "typescript": "~5.8.2", - "vite": "^6.2.3", + "vite": "^6.4.4", "@types/express": "^4.17.21" + }, + "overrides": { + "body-parser": "^1.20.9", + "proxy-addr": "^2.0.8", + "qs": "^6.16.1", + "source-map-js": "^1.2.2" } } From 25c33a8509e2e9f2cf3f05a89fc1c5c3ca4cc3c4 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:11:45 +0530 Subject: [PATCH 55/95] Add safe targeted npm updates and lockfile persistence --- .github/workflows/chatbot-pr-check.yml | 27 ++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index 7724878ab..e289cb508 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -19,7 +19,7 @@ on: - '.github/workflows/chatbot-pr-check.yml' permissions: - contents: read + contents: write jobs: edge-function-check: @@ -39,13 +39,36 @@ jobs: working-directory: ai-chatbot steps: - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v6 with: node-version: 20 cache: npm cache-dependency-path: ai-chatbot/package-lock.json - name: Install dependencies - run: npm install + run: npm install --no-audit + - name: Update only audited vulnerable packages + run: npm update vite body-parser proxy-addr qs source-map-js --no-audit + - name: Persist patched dependency lockfile + env: + TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + run: | + if [ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ] && [ -n "$TARGET_BRANCH" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package-lock.json + if ! git diff --cached --quiet; then + git commit -m "chore(chatbot): patch audited dependencies [skip ci]" + git push origin "HEAD:$TARGET_BRANCH" + fi + fi + - name: Audit dependency tree + run: npm audit --audit-level=moderate + - name: Audit dependency details + if: always() + run: npm audit --json > /tmp/studenthubhelp-npm-audit.json || true - name: Audit dependency tree (non-blocking report) run: | npm audit --json > /tmp/studenthubhelp-npm-audit.json || true From 4e9c74677552fc660a87e69a5c57f780d27131fb Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:11:57 +0530 Subject: [PATCH 56/95] Make npm audit a zero-vulnerability CI gate --- .github/workflows/chatbot-pr-check.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index e289cb508..fe558ff89 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -65,11 +65,9 @@ jobs: fi fi - name: Audit dependency tree - run: npm audit --audit-level=moderate - - name: Audit dependency details + run: npm audit + - name: Report dependency advisories if: always() - run: npm audit --json > /tmp/studenthubhelp-npm-audit.json || true - - name: Audit dependency tree (non-blocking report) run: | npm audit --json > /tmp/studenthubhelp-npm-audit.json || true node - <<'NODE' From 746158a2278f708f7d1eceadeb0ec5b758002189 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:42:21 +0000 Subject: [PATCH 57/95] chore(chatbot): patch audited dependencies [skip ci] --- ai-chatbot/package-lock.json | 54 ++++++++++++++---------------------- 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/ai-chatbot/package-lock.json b/ai-chatbot/package-lock.json index b00258fa0..63e7a55b3 100644 --- a/ai-chatbot/package-lock.json +++ b/ai-chatbot/package-lock.json @@ -17,7 +17,7 @@ "motion": "^12.23.24", "react": "^19.0.1", "react-dom": "^19.0.1", - "vite": "^6.2.3" + "vite": "^6.4.4" }, "devDependencies": { "@types/express": "^4.17.21", @@ -27,7 +27,7 @@ "tailwindcss": "^4.1.14", "tsx": "^4.21.0", "typescript": "~5.8.2", - "vite": "^6.2.3" + "vite": "^6.4.4" } }, "node_modules/@babel/code-frame": { @@ -1736,9 +1736,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.8", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1774,22 +1774,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -3253,9 +3237,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -3263,12 +3247,16 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -3563,9 +3551,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -4198,9 +4186,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", From 164e7e70b87d0a8d07d59a0389a603e7643d82c5 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:12:32 +0530 Subject: [PATCH 58/95] Enable RLS on private distributed chatbot rate-limit table --- .../migrations/20261108190000_chatbot_distributed_rate_limit.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql b/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql index 0abaf161a..26f28c6a8 100644 --- a/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql +++ b/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql @@ -6,6 +6,7 @@ create table if not exists private.chatbot_rate_limits ( hit_count integer not null default 0, primary key (scope, key_hash) ); +alter table private.chatbot_rate_limits enable row level security; revoke all on table private.chatbot_rate_limits from public, anon, authenticated; grant all on table private.chatbot_rate_limits to service_role; From 3704adbcefa924e2ba2df2acee4e6387ac8da22b Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:13:00 +0530 Subject: [PATCH 59/95] Align rate-limit migration filename with applied Supabase migration version --- ...8164241_chatbot_distributed_rate_limit.sql | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 supabase/migrations/20261108164241_chatbot_distributed_rate_limit.sql diff --git a/supabase/migrations/20261108164241_chatbot_distributed_rate_limit.sql b/supabase/migrations/20261108164241_chatbot_distributed_rate_limit.sql new file mode 100644 index 000000000..26f28c6a8 --- /dev/null +++ b/supabase/migrations/20261108164241_chatbot_distributed_rate_limit.sql @@ -0,0 +1,37 @@ +-- Durable, atomic rate limiting for the StudentHubHelp chatbot. +create table if not exists private.chatbot_rate_limits ( + scope text not null, + key_hash text not null, + window_started_at timestamptz not null, + hit_count integer not null default 0, + primary key (scope, key_hash) +); +alter table private.chatbot_rate_limits enable row level security; +revoke all on table private.chatbot_rate_limits from public, anon, authenticated; +grant all on table private.chatbot_rate_limits to service_role; + +create or replace function public.consume_chatbot_rate_limit( + p_scope text, p_key text, p_limit integer, p_window_seconds integer +) returns boolean +language plpgsql security definer set search_path = '' +as $$ +declare v_count integer; +begin + if p_scope not in ('chat', 'owner_lead') or p_key is null or length(p_key)=0 + or p_limit < 1 or p_limit > 1000 or p_window_seconds < 1 or p_window_seconds > 86400 then + raise exception 'Invalid rate limit arguments'; + end if; + insert into private.chatbot_rate_limits(scope,key_hash,window_started_at,hit_count) + values(p_scope,md5(p_key),now(),1) + on conflict(scope,key_hash) do update + set window_started_at=case when private.chatbot_rate_limits.window_started_at <= now()-make_interval(secs=>p_window_seconds) then now() else private.chatbot_rate_limits.window_started_at end, + hit_count=case when private.chatbot_rate_limits.window_started_at <= now()-make_interval(secs=>p_window_seconds) then 1 else private.chatbot_rate_limits.hit_count+1 end + returning hit_count into v_count; + if mod(extract(epoch from now())::bigint,100)=0 then + delete from private.chatbot_rate_limits where window_started_at < now()-interval '2 days'; + end if; + return v_count <= p_limit; +end; +$$; +revoke all on function public.consume_chatbot_rate_limit(text,text,integer,integer) from public,anon,authenticated; +grant execute on function public.consume_chatbot_rate_limit(text,text,integer,integer) to service_role; From cdec1f54e4da098ee0ad1311ce6cb6cd3769be08 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:13:03 +0530 Subject: [PATCH 60/95] Remove duplicate pending rate-limit migration file --- ...8190000_chatbot_distributed_rate_limit.sql | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql diff --git a/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql b/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql deleted file mode 100644 index 26f28c6a8..000000000 --- a/supabase/migrations/20261108190000_chatbot_distributed_rate_limit.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Durable, atomic rate limiting for the StudentHubHelp chatbot. -create table if not exists private.chatbot_rate_limits ( - scope text not null, - key_hash text not null, - window_started_at timestamptz not null, - hit_count integer not null default 0, - primary key (scope, key_hash) -); -alter table private.chatbot_rate_limits enable row level security; -revoke all on table private.chatbot_rate_limits from public, anon, authenticated; -grant all on table private.chatbot_rate_limits to service_role; - -create or replace function public.consume_chatbot_rate_limit( - p_scope text, p_key text, p_limit integer, p_window_seconds integer -) returns boolean -language plpgsql security definer set search_path = '' -as $$ -declare v_count integer; -begin - if p_scope not in ('chat', 'owner_lead') or p_key is null or length(p_key)=0 - or p_limit < 1 or p_limit > 1000 or p_window_seconds < 1 or p_window_seconds > 86400 then - raise exception 'Invalid rate limit arguments'; - end if; - insert into private.chatbot_rate_limits(scope,key_hash,window_started_at,hit_count) - values(p_scope,md5(p_key),now(),1) - on conflict(scope,key_hash) do update - set window_started_at=case when private.chatbot_rate_limits.window_started_at <= now()-make_interval(secs=>p_window_seconds) then now() else private.chatbot_rate_limits.window_started_at end, - hit_count=case when private.chatbot_rate_limits.window_started_at <= now()-make_interval(secs=>p_window_seconds) then 1 else private.chatbot_rate_limits.hit_count+1 end - returning hit_count into v_count; - if mod(extract(epoch from now())::bigint,100)=0 then - delete from private.chatbot_rate_limits where window_started_at < now()-interval '2 days'; - end if; - return v_count <= p_limit; -end; -$$; -revoke all on function public.consume_chatbot_rate_limit(text,text,integer,integer) from public,anon,authenticated; -grant execute on function public.consume_chatbot_rate_limit(text,text,integer,integer) to service_role; From b4abb7bd4e3ba5881a0dac55227759cf96a6aa73 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:15:26 +0530 Subject: [PATCH 61/95] Fix combo budget response template literal syntax --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index b61f41281..ceaa6d17c 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -440,7 +440,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(!combo)return await chatResponse({reply:`${ct} mein ₹${budgetFromText.toLocaleString("en-IN")} ke andar verified monthly price ke saath hostel + tiffin ka complete combo abhi nahi mila. Aap budget badha sakte hain ya dono categories alag-alag search kar sakte hain.`,intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Hostel alag search karo","Tiffin alag search karo","Budget badhakar try karo"],grounded:true,sessionId},{headers:H},sessionId,msg); const hostelCard=card(combo.hostel),tiffinCard=card(combo.tiffin),total=combo.total,savings=budgetFromText-total; const budgetPlan={targetBudget:budgetFromText,city:ct,livingType:"pg_tiffin" as const,items:[{category:"rent" as const,label:`${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month`,amount:combo.hostelCost,note:[hostelCard.area,hostelCard.city].filter(Boolean).join(", ")},{category:"food" as const,label:`${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month`,amount:combo.tiffinCost,note:[tiffinCard.area,tiffinCard.city].filter(Boolean).join(", ")}],totalEstimated:total,savingsOrOverrun:savings,status:savings>300?"under_budget" as const:savings< -300?"over_budget" as const:"balanced" as const,savingsTips:["Prices are taken from active StudentHubHelp listings; confirm inclusions and availability with the provider before payment."],recommendedCombo:`${hostelCard.name} + ${tiffinCard.name}`}; - const reply=\`Aapke ₹\${budgetFromText.toLocaleString("en-IN")} monthly budget mein \${ct} ka best available combo:\n\n🏠 **Hostel:** \${hostelCard.name} — ₹\${combo.hostelCost.toLocaleString("en-IN")}/month\n🍲 **Tiffin:** \${tiffinCard.name} — ₹\${combo.tiffinCost.toLocaleString("en-IN")}/month\n\n💰 **Total:** ₹\${total.toLocaleString("en-IN")}/month\n**Bachat:** ₹\${savings.toLocaleString("en-IN")}/month\n\nYeh prices active listing records se li gayi hain; final inclusions aur availability provider se confirm karein.\`; + const reply=`Aapke ₹${budgetFromText.toLocaleString("en-IN")} monthly budget mein ${ct} ka best available combo:\n\n🏠 **Hostel:** ${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month\n🍲 **Tiffin:** ${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month\n\n💰 **Total:** ₹${total.toLocaleString("en-IN")}/month\n**Bachat:** ₹${savings.toLocaleString("en-IN")}/month\n\nYeh prices active listing records se li gayi hain; final inclusions aur availability provider se confirm karein.`; return await chatResponse({reply,intent:"Combined Budget Plan",primaryTopic:"Hostel + Tiffin Combo Budget",budgetPlan,recommendedProperties:[hostelCard,tiffinCard],recommendations:[hostelCard,tiffinCard],suggestedFollowUps:["Hostel ki full details batao","Tiffin ki full details batao","Aur combo options dikhao"],grounded:true,sessionId,targetCity:ct,targetCategory:"hostel+tiffin"},{headers:H},sessionId,msg); } let rows=await multiDirect(aiCats.length?aiCats:(c?[c]:(nearCats.length?nearCats:["hostel"])),ct,u.budgetMax??undefined,u.area||u.locality||undefined,u.gender||genderWanted(msg)); From 2f6a4890c949b831d43b9afb1f839edf65f041e0 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:16:21 +0530 Subject: [PATCH 62/95] Add optional GPS support to public chatbot widget --- widget.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/widget.js b/widget.js index ede0ac7b9..e25ca8747 100644 --- a/widget.js +++ b/widget.js @@ -18,6 +18,8 @@ const SHH_WIDGET=(()=>{ let history=[],speaking=false,busy=false,expanded=false,lastData=null;let sessionId=(window.crypto&&crypto.randomUUID)?crypto.randomUUID():'chat-'+Date.now(); const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const safeUrl=v=>{try{const u=new URL(String(v));return ['http:','https:'].includes(u.protocol)?u.href:''}catch{return ''}};const icon=t=>({hostel:'🏠',tiffin:'🍱',library:'📚',cafe:'☕',bookstore:'📖'})[t]||'📍'; const strip=t=>String(t??'').replace(/\s{2,}/g,' ').trim();function formatText(t){let x=esc(strip(t));x=x.replace(/\*\*(.+?)\*\*/g,'$1');return x.replace(/\n/g,'
')} + function asksForNearbyLocation(text){return /(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(text)&&!/(allen|clc|vibrant|matrix|gurukripa|piprali road|piprali rd|nawalgarh road)/i.test(text)} + function getUserDeviceLocation(){if(!navigator.geolocation)return Promise.resolve(null);return new Promise(resolve=>navigator.geolocation.getCurrentPosition(pos=>resolve({lat:pos.coords.latitude,lng:pos.coords.longitude}),()=>resolve(null),{timeout:5000,maximumAge:60000,enableHighAccuracy:false}))} function speak(text){if(!speaking||!window.speechSynthesis)return;window.speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(String(text).replace(/[*_#]/g,' ').replace(/\n+/g,'. '));u.lang='hi-IN';const vs=window.speechSynthesis.getVoices();const v=vs.find(x=>/^hi(-|_)/i.test(x.lang))||vs.find(x=>/^en(-|_)/i.test(x.lang));if(v)u.voice=v;window.speechSynthesis.speak(u)} function addMessage(text,isBot,data){const wrap=document.createElement('div');wrap.style.cssText='align-self:'+(isBot?'stretch':'flex-end')+';max-width:'+(isBot?'100%':'88%')+';width:'+(isBot?'100%':'auto')+';';const bubble=document.createElement('div');bubble.style.cssText='background:'+(isBot?'#fff':'#071a33')+';color:'+(isBot?'#172033':'#fff')+';border:'+(isBot?'1px solid #e1e8f0':'0')+';border-radius:18px;padding:11px 13px;font-size:13px;line-height:1.58;box-shadow:0 3px 12px rgba(15,23,42,.06);word-break:break-word;';if(isBot){const meta=document.createElement('div');meta.style.cssText='display:flex;align-items:center;gap:6px;font-size:9px;font-weight:800;color:#64748b;margin-bottom:5px';meta.innerHTML='● StudentHubHelp AI';bubble.appendChild(meta)}const body=document.createElement('div');body.innerHTML=formatText(text);bubble.appendChild(body);wrap.appendChild(bubble);if(isBot&&data)renderData(wrap,data);messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight;if(isBot)speak(strip(text))} function renderPropertyCard(p){const card=document.createElement('div');card.className='shh-card';const loc=[p.area,p.city].filter(Boolean).join(', '),phone=p.phone||p.whatsapp,maps=safeUrl(p.mapsUrl),details=safeUrl(p.detailsUrl);const top=document.createElement('div');top.style.cssText='display:flex;justify-content:space-between;gap:8px;align-items:flex-start';top.innerHTML='
'+icon(p.type)+' '+esc(p.name||'Active listing')+'
'+(loc?'
📍 '+esc(loc)+'
':'')+'
'+(p.verified?'✓ Verified Active':'Active')+'';card.appendChild(top);if(p.distanceLabel){const z=document.createElement("div");z.style.cssText="margin-top:5px;font-size:10px;font-weight:700;color:#475569;line-height:1.4";z.textContent=p.distanceLabel;card.appendChild(z)}if(p.price){const z=document.createElement('div');z.className='shh-card-price';z.textContent=p.price;card.appendChild(z)}if(p.rating!==undefined&&p.rating!==null){const z=document.createElement('div');z.style.cssText='margin-top:5px;font-size:10px;font-weight:900;color:#8a6415';z.textContent='⭐ '+p.rating;card.appendChild(z)}if(p.address){const z=document.createElement('div');z.style.cssText='margin-top:7px;font-size:10px;color:#475569;line-height:1.45';z.innerHTML='Address: '+esc(p.address);card.appendChild(z)}if(p.description){const z=document.createElement('div');z.className='shh-card-desc';z.textContent=strip(p.description);card.appendChild(z)}const vals=Array.isArray(p.highlights)?p.highlights.map(strip).filter(Boolean):[];if(vals.length){const h=document.createElement('div');h.className='shh-tags';vals.slice(0,6).forEach(x=>{const z=document.createElement('span');z.className='shh-tag';z.textContent=x;h.appendChild(z)});card.appendChild(h)}const acts=document.createElement('div');acts.className='shh-actions';if(phone){const a=document.createElement('a');a.className='call';a.href='tel:'+encodeURIComponent(phone);a.textContent='📞 Call Owner';acts.appendChild(a)}if(maps){const a=document.createElement('a');a.className='map';a.href=maps;a.target='_blank';a.rel='noopener noreferrer';a.textContent='🗺️ Google Maps';acts.appendChild(a)}if(details){const a=document.createElement('a');a.className='view';a.href=details;a.textContent='View';acts.appendChild(a)}if(acts.children.length)card.appendChild(acts);return card} @@ -34,7 +36,7 @@ const SHH_WIDGET=(()=>{ }; messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight; } - async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return;if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),15000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[]}),signal:ctl.signal})}finally{clearTimeout(timer)}const d=await r.json().catch(()=>({}));if(!r.ok){const err=new Error('Chat request failed ('+r.status+')');err.payload=d;err.status=r.status;throw err}lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support'}catch(e){const payload=e?.payload||{};if(payload.error_id)console.error('[StudentHubHelp chatbot error]',payload.error_id);addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':(payload.reply||'Ji, live AI service me temporary issue aaya. Please try again.'),true);if(payload.can_retry||e?.name==='AbortError'||Number(e?.status)>=500){const retryWrap=document.createElement('div');retryWrap.style.cssText='display:flex;justify-content:flex-start;margin:4px 0 8px';const retry=document.createElement('button');retry.type='button';retry.textContent='↻ Retry';retry.style.cssText='border:1px solid #cbd5e1;border-radius:999px;padding:7px 13px;background:#fff;color:#334155;font-size:12px;font-weight:800;cursor:pointer';retry.onclick=()=>{retry.disabled=true;retry.textContent='Retrying…';sendText(text)};retryWrap.appendChild(retry);messages.appendChild(retryWrap);messages.scrollTop=messages.scrollHeight}document.getElementById('shh-status').textContent='Ready · Please try again'}finally{busy=false}} + async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return;if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const clientCoords=asksForNearbyLocation(text)?await getUserDeviceLocation():null;const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),15000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,clientCoords,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[]}),signal:ctl.signal})}finally{clearTimeout(timer)}const d=await r.json().catch(()=>({}));if(!r.ok){const err=new Error('Chat request failed ('+r.status+')');err.payload=d;err.status=r.status;throw err}lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support'}catch(e){const payload=e?.payload||{};if(payload.error_id)console.error('[StudentHubHelp chatbot error]',payload.error_id);addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':(payload.reply||'Ji, live AI service me temporary issue aaya. Please try again.'),true);if(payload.can_retry||e?.name==='AbortError'||Number(e?.status)>=500){const retryWrap=document.createElement('div');retryWrap.style.cssText='display:flex;justify-content:flex-start;margin:4px 0 8px';const retry=document.createElement('button');retry.type='button';retry.textContent='↻ Retry';retry.style.cssText='border:1px solid #cbd5e1;border-radius:999px;padding:7px 13px;background:#fff;color:#334155;font-size:12px;font-weight:800;cursor:pointer';retry.onclick=()=>{retry.disabled=true;retry.textContent='Retrying…';sendText(text)};retryWrap.appendChild(retry);messages.appendChild(retryWrap);messages.scrollTop=messages.scrollHeight}document.getElementById('shh-status').textContent='Ready · Please try again'}finally{busy=false}} const tts=header.querySelector('#shh-tts');tts.onclick=()=>{speaking=!speaking;tts.textContent=speaking?'🔊':'🔇';if(!speaking&&window.speechSynthesis)window.speechSynthesis.cancel()};header.querySelector('#shh-max').onclick=()=>{expanded=!expanded;root.classList.toggle('shh-expanded',expanded);header.querySelector('#shh-max').textContent=expanded?'↙':'↗'};header.querySelector('#shh-close-btn').onclick=()=>{win.style.display='none';launcher.style.display='flex'};launcher.onclick=()=>{win.style.display='flex';launcher.style.display='none';messages.scrollTop=messages.scrollHeight};input.querySelector('#shh-send-btn').onclick=()=>sendText(); const field=input.querySelector('#shh-input');field.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();sendText()}});input.querySelector('#shh-clear').onclick=()=>{history=[];lastData=null;messages.innerHTML='';document.getElementById('shh-topic').style.display='none';addMessage(getWelcome(),true)}; function getWelcome(){return'Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance AI Assistant** hoon.\n\nAap jo bhi requirement batayenge—Hostel, Tiffin, Library, Cafe, Bookstore, area, budget ya nearby location—main aapki requirement samajhkar live StudentHubHelp listings mein relevant options dhoondunga.\n\n**Aapko kis cheez ki jankari chahiye?**'} From cece043512a9ad0ba05413d156cf2e94d7b6ca3f Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:16:30 +0530 Subject: [PATCH 63/95] Add public widget JavaScript syntax validation --- .github/workflows/chatbot-pr-check.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index fe558ff89..c666e86c3 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -87,8 +87,10 @@ jobs: })); } NODE - - name: Validate regression test script syntax - run: node --check tests/chatbot-regression.mjs + - name: Validate regression and public widget scripts + run: | + node --check tests/chatbot-regression.mjs + node --check ../widget.js - name: Type-check React chatbot component run: npx tsc --noEmit --skipLibCheck --jsx react-jsx --target ES2020 --module ESNext --moduleResolution bundler --allowImportingTsExtensions --types vite/client src/components/ChatWidget.tsx - name: Restore Vite source entry for validation From 8cff68320e4ec15fe71c8741c1f109b7e7b623a1 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:16:53 +0530 Subject: [PATCH 64/95] Use primary-key cursor pagination for category search --- supabase/functions/studenthubhelp-chat/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index ceaa6d17c..5b46d0de4 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -31,7 +31,7 @@ function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,l function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.type,p.gender_type,p.gender,p.city,p.area,p.service_area,p.address,p.landmark,p.nearby_coaching,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ const table=SRC.find(x=>x[1]===type)?.[0];if(!table)return []; - const pageSize=250,maxRows=5000;const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),offset:"0"}); + const pageSize=250,maxRows=5000;const params=new URLSearchParams({select:"*",status:"eq.active",limit:String(pageSize),order:"id.asc"}); if(ct)params.set("city",`ilike.*${ct.replace(/[%,()]/g,"")}*`); const orGroups:string[]=[]; if(maxBudget!==undefined&&Number.isFinite(maxBudget)&&maxBudget>0){ @@ -48,8 +48,8 @@ async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:str } if(orGroups.length===1)params.set("or",`(${orGroups[0]})`); else if(orGroups.length>1)params.set("and",`(${orGroups.map(group=>`or(${group})`).join(",")})`); - const page:any[]=[];let offset=0;while(offset(`/rest/v1/${table}?${params.toString()}`);const batch=Array.isArray(rows)?rows:[];page.push(...batch);if(batch.length(`/rest/v1/${table}?${params.toString()}`);const batch=Array.isArray(rows)?rows:[];if(!batch.length)break;page.push(...batch);const lastId=batch[batch.length-1]?.id;if(lastId===undefined||batch.length({...p,_type:type})).filter(p=>!ct||n(p.city||"").includes(n(ct))); } async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationTerm?:string,genderFilter?:string){ From d27e623078805da85628c1d3884d54a165dd07a2 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:17:20 +0530 Subject: [PATCH 65/95] Keep legacy Express server out of production build --- ai-chatbot/package.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ai-chatbot/package.json b/ai-chatbot/package.json index 6b7e01ae6..ff5c126dd 100644 --- a/ai-chatbot/package.json +++ b/ai-chatbot/package.json @@ -4,12 +4,13 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "tsx server.ts", - "build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs", - "start": "node dist/server.cjs", + "dev": "vite", + "build": "vite build", + "start": "vite preview --host 0.0.0.0", "preview": "vite preview", "clean": "rm -rf dist server.js", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "dev:legacy-server": "tsx server.ts" }, "dependencies": { "@google/genai": "^2.4.0", From ab91fa5aa6740d1d2cec5008d6986194a00811bc Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:17:25 +0530 Subject: [PATCH 66/95] Document secure rate-limit fallback and legacy server isolation --- ai-chatbot/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ai-chatbot/README.md b/ai-chatbot/README.md index d48ea4e63..fd8df8ecb 100644 --- a/ai-chatbot/README.md +++ b/ai-chatbot/README.md @@ -16,7 +16,7 @@ Both browser clients use the same production endpoint. Do not point either clien - Only rows with `status = 'active'` are eligible for public recommendations. - The Edge Function uses the private `chatbot_rate_limits` table through the `consume_chatbot_rate_limit` security-definer RPC. Apply the checked-in migration before deploying the Edge Function version that calls it. - Keep the Supabase service-role key server-side only. Browser clients use the public/publishable key. -- The chatbot fails closed when its admin config is missing/unavailable or a trusted Cloudflare client-IP header is absent. +- The chatbot fails closed when its admin config is missing/unavailable. If Cloudflare's client-IP header is absent, it falls back to lower-threshold session/user-agent and explicitly untrusted proxy keys; this fallback is weaker than a verified edge IP and must be checked in staging. ## Environment @@ -26,9 +26,11 @@ For the Edge Function, configure the Supabase URL/key and Gemini key as Supabase `server.ts` and its `/api/*` routes are a legacy local preview harness, not the production source of truth. Its config and lead arrays are in-memory and reset when the process restarts. Do not deploy those endpoints as a public production API or use them for authoritative chatbot settings, leads, or property data. -Run locally only when intentionally testing the legacy preview: +Run the React UI locally with `npm run dev`. To intentionally start the isolated legacy Express preview, use `npm run dev:legacy-server`. The production build is static Vite output only; it does not bundle `server.ts` or the legacy `/api/*` routes. ```bash npm install npm run dev +# Optional legacy preview only: +npm run dev:legacy-server ``` From 3d9fd537c3ef84084fac69540c846ed9e14bfcb3 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:17:29 +0530 Subject: [PATCH 67/95] Mark Express API as local-only preview server --- ai-chatbot/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ai-chatbot/server.ts b/ai-chatbot/server.ts index 9f816a5d6..e7b7f049b 100644 --- a/ai-chatbot/server.ts +++ b/ai-chatbot/server.ts @@ -1,3 +1,4 @@ +// LEGACY LOCAL PREVIEW ONLY. Not imported by the React client and excluded from the production Vite build. import express, { Request, Response } from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; From 7c1f85f445f6247d397b28822ff0e5047dbb15d8 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:18:47 +0530 Subject: [PATCH 68/95] Enforce explicit single-category search and detect intentional multi-category requests --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 5b46d0de4..7f297c2f0 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -349,7 +349,7 @@ Deno.serve(async(req:Request)=>{ } if(isAccountSupport(msg))return await chatResponse({reply:accountSupportReply(msg),intent:"Account / Login Support",primaryTopic:"Account access",suggestedFollowUps:["I forgot my password","Google login is not working","Dashboard is not opening","Contact support"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); if(isContact(msg)||String(b?.action||"")==="contact_support")return await chatResponse({reply:"Ji bilkul 😊 StudentHubHelp team se contact ke liye:\n\n📧 Email: studenthubhelpp@gmail.com\n📞 Phone: +91 9929718264",intent:"Support / Contact",primaryTopic:"Contact StudentHubHelp",suggestedFollowUps:["Find a hostel","Find a library","Search by area","Back to search"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); - const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),aiCats=u.categories||[],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); + const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),multiCategoryRequest=/(?:\\b(?:and|plus|with)\\b|\\+|\\baur\\b|और)/i.test(n(msg)),detectedCats=["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\\\bpg\\\\b|हॉस्टल|पीजी":type==="tiffin"?"tiffin|mess|meal|टिफिन|मेस":type==="library"?"library|reading room|लाइब्रेरी":type==="cafe"?"cafe|कैफे":"bookstore|book shop|stationery|बुकस्टोर","i").test(n(msg))),aiCats=explicitCat&&!multiCategoryRequest?[explicitCat]:[...new Set([...(u.categories||[]),...detectedCats])],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); const clientCoords=coords(b?.clientCoords); const genericNearbyIntent=/(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(n(msg))&&!u.landmark&&!u.area&&!u.locality&&!nearTerm(msg); if(propertyMode&&genericNearbyIntent&&!clientCoords)return await chatResponse({reply:"Aapke aas-paas listings dikhane ke liye device location ki zaroorat hai. Kripya location access allow karein ya apna landmark/area batayein (jaise Piprali Road ya Allen ke paas).",intent:"Location Required",primaryTopic:"Nearby listing search",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by area","Search by city"],grounded:true,sessionId},{headers:H},sessionId,msg); From 8ed410123b3a7964e94b7f6e9b73c7eb4cea61f0 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:18:58 +0530 Subject: [PATCH 69/95] Correct explicit category detection regex escaping --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 7f297c2f0..c1dc8a6a8 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -349,7 +349,7 @@ Deno.serve(async(req:Request)=>{ } if(isAccountSupport(msg))return await chatResponse({reply:accountSupportReply(msg),intent:"Account / Login Support",primaryTopic:"Account access",suggestedFollowUps:["I forgot my password","Google login is not working","Dashboard is not opening","Contact support"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); if(isContact(msg)||String(b?.action||"")==="contact_support")return await chatResponse({reply:"Ji bilkul 😊 StudentHubHelp team se contact ke liye:\n\n📧 Email: studenthubhelpp@gmail.com\n📞 Phone: +91 9929718264",intent:"Support / Contact",primaryTopic:"Contact StudentHubHelp",suggestedFollowUps:["Find a hostel","Find a library","Search by area","Back to search"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); - const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),multiCategoryRequest=/(?:\\b(?:and|plus|with)\\b|\\+|\\baur\\b|और)/i.test(n(msg)),detectedCats=["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\\\bpg\\\\b|हॉस्टल|पीजी":type==="tiffin"?"tiffin|mess|meal|टिफिन|मेस":type==="library"?"library|reading room|लाइब्रेरी":type==="cafe"?"cafe|कैफे":"bookstore|book shop|stationery|बुकस्टोर","i").test(n(msg))),aiCats=explicitCat&&!multiCategoryRequest?[explicitCat]:[...new Set([...(u.categories||[]),...detectedCats])],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); + const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),multiCategoryRequest=/(?:\b(?:and|plus|with)\b|\+|\baur\b|और)/i.test(n(msg)),detectedCats=["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\bpg\\b|हॉस्टल|पीजी":type==="tiffin"?"tiffin|mess|meal|टिफिन|मेस":type==="library"?"library|reading room|लाइब्रेरी":type==="cafe"?"cafe|कैफे":"bookstore|book shop|stationery|बुकस्टोर","i").test(n(msg))),aiCats=explicitCat&&!multiCategoryRequest?[explicitCat]:[...new Set([...(u.categories||[]),...detectedCats])],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); const clientCoords=coords(b?.clientCoords); const genericNearbyIntent=/(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(n(msg))&&!u.landmark&&!u.area&&!u.locality&&!nearTerm(msg); if(propertyMode&&genericNearbyIntent&&!clientCoords)return await chatResponse({reply:"Aapke aas-paas listings dikhane ke liye device location ki zaroorat hai. Kripya location access allow karein ya apna landmark/area batayein (jaise Piprali Road ya Allen ke paas).",intent:"Location Required",primaryTopic:"Nearby listing search",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by area","Search by city"],grounded:true,sessionId},{headers:H},sessionId,msg); From 6d91d554349d0fb96494ab829650d06365e2134e Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:19:25 +0530 Subject: [PATCH 70/95] Use verified monthly tiffin prices only in budget calculations --- supabase/functions/studenthubhelp-chat/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index c1dc8a6a8..21102674a 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -38,7 +38,7 @@ async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:str const budget=String(Math.floor(maxBudget)); orGroups.push(type==="hostel" ? `monthly_rent.lte.${budget},and(monthly_rent.is.null,monthly_fee.lte.${budget}),and(monthly_rent.is.null,monthly_fee.is.null,price.lte.${budget})` - : type==="tiffin" ? `monthly_plan.lte.${budget},and(monthly_plan.is.null,monthly_fee.lte.${budget}),and(monthly_plan.is.null,monthly_fee.is.null,price.lte.${budget})` : `monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget})`); + : type==="tiffin" ? `monthly_plan.lte.${budget},and(monthly_plan.is.null,monthly_fee.lte.${budget})` : `monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget})`); } const term=String(locationTerm||"").replace(/[^a-zA-Z0-9\u0900-\u097f -]/g," ").replace(/\s+/g," ").trim(); if(term)orGroups.push(["name","area","address","landmark","nearby_coaching","description"].map(col=>`${col}.ilike.*${term}*`).join(",")); @@ -57,7 +57,7 @@ async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationT const groups=await Promise.all(unique.map(type=>direct(type,ct,maxBudget,locationTerm,genderFilter))); return groups.flat(); } -function monthlyAmount(p:any,type:string){const value=Number(type==="hostel"?(p.monthly_rent??p.monthly_fee??p.price):(p.monthly_plan??p.monthly_fee??p.price));return Number.isFinite(value)&&value>0?value:undefined} +function monthlyAmount(p:any,type:string){const value=Number(type==="hostel"?(p.monthly_rent??p.monthly_fee??p.price):(p.monthly_plan??p.monthly_fee));return Number.isFinite(value)&&value>0?value:undefined} async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ const hostelCap=Math.floor(totalBudget*0.70),tiffinCap=Math.floor(totalBudget*0.35); const [hostelRows,tiffinRows]=await Promise.all([direct("hostel",cityName,hostelCap,undefined,genderFilter),direct("tiffin",cityName,tiffinCap)]); @@ -70,7 +70,7 @@ async function planComboBudget(cityName:string,totalBudget:number,genderFilter?: } function genderWanted(s:string){const x=n(normalizeSearchTerms(s));if(/\b(girls|girl|female|ladki|ladkiyo|ladkiyon|महिला|लड़क|लड़की|girls hostel)\b/i.test(x))return "girls";if(/\b(boys|boy|male|ladke|ladkon|लड़के|लड़का|boys hostel)\b/i.test(x))return "boys";return ""} function areaWanted(s:string){const x=n(normalizeSearchTerms(s));if(/nawalgarh road|nawalgarh rd|नवलगढ़ रोड/i.test(x))return "nawalgarh";if(/piprali road|piprali rd|पिपराली रोड/i.test(x))return "piprali";return ""} -function priceLabel(p:any,c:string){const v=Number(p.monthly_rent??(c==="tiffin"?p.monthly_plan:undefined)??p.monthly_fee??p.monthly_charge??p.price);if(!Number.isFinite(v)||v<=0)return "";if(c==="hostel"||c==="tiffin"||c==="library")return "₹"+v.toLocaleString("en-IN")+"/mo";return "₹"+v.toLocaleString("en-IN")} +function priceLabel(p:any,c:string){const v=Number(c==="tiffin"?(p.monthly_plan??p.monthly_fee):(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price));if(!Number.isFinite(v)||v<=0)return "";if(c==="hostel"||c==="tiffin"||c==="library")return "₹"+v.toLocaleString("en-IN")+"/mo";return "₹"+v.toLocaleString("en-IN")} function card(p:any){const c=p._type,price=priceLabel(p,c),d=p._distanceKm;return{id:String(p.id),name:p.name||p.title||p.service_name||p.business_name||"Active listing",type:c,city:p.city||"",area:p.area||p.service_area||"",address:p.address||"",phone:([p.phone,p.contact_phone,p.whatsapp,p.whatsapp_number].find(v=>{const s=String(v??"").trim();return s&&s!=="0"&&s.replace(/\D/g,"").length>=7})||""),rating:num(p.rating),verified:p.verified===true,price,distanceKm:d===undefined?null:Number(d.toFixed(1)),distanceVerified:d!==undefined,distanceLabel:d===undefined?(p._nearbyAreaMatch?`${p.area||p.service_area||p.city||"Area"} area match · exact distance unavailable`:""):`~${d.toFixed(1)} km (GPS straight-line)`,nearbyMatch:p._nearbyVerified===true||p._nearbyAreaMatch===true,highlights:["wifi","ac_available","ac","cctv","food_available","mess_available","available_beds","room_types","room_sharing","gender_type","timing","timings","delivery_available","nearby_coaching"].map(k=>[k,p[k]]).filter(([,v])=>v!==null&&v!==undefined&&String(v).trim()!==""&&String(v).toLowerCase()!=="false").slice(0,4).map(([k,v])=>k+": "+String(v)),description:p.description&&String(p.description).trim().length<=280?String(p.description).trim():"",mapsUrl:p.google_maps_url||"",detailsUrl:p.slug?`property-details.html?type=${c}&slug=${encodeURIComponent(p.slug)}`:undefined}} function rank(p:any,q:string,ct:string,near:string){const x=searchText(p),t=n(q);let s=0;if(p._distanceKm!==undefined)s+=Math.max(0,25-p._distanceKm*5);const a=areaWanted(q);if(a&&x.includes(a))s+=25;if(ct&&n(p.city||"").includes(n(ct)))s+=30;for(const w of t.split(" ").filter(z=>z.length>2).slice(0,20))if(x.includes(w))s+=2;if(near){if(near==="allen"||near==="clc"){if(p._distanceKm!==undefined)s+=Math.max(0,20-p._distanceKm*5);if(x.includes(near))s+=10}else if(near==="vibrant"){if(x.includes("vibrant"))s+=20;if(x.includes("nawalgarh"))s+=8}else if(near==="matrix"||near==="gurukripa"){if(x.includes(near))s+=30;else s-=3}else if(x.includes(n(near)))s+=20}if(p.verified===true)s+=4;if(Number.isFinite(Number(p.rating)))s+=Math.min(5,Number(p.rating));return s} function isGreeting(s:string){return /^(hi|hello|hey|namaste|good morning|good afternoon|good evening|good night|gm|gn|hii|hlo|नमस्ते|सुप्रभात|शुभ प्रभात|शुभ संध्या)[!.\s]*$/i.test(s.trim())} @@ -201,7 +201,7 @@ function mergeIntent(ai:any,msg:string,history:any[]){ if(o.categories.length)o.category=o.categories[0]; return o; } -function budgetValue(p:any){return num(p.monthly_rent??p.monthly_plan??p.monthly_fee??p.monthly_charge??p.price)} +function budgetValue(p:any,type?:string){return num(type==="tiffin"?(p.monthly_plan??p.monthly_fee):(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price))} function matchesFacility(p:any,wanted:string[]){if(!wanted.length)return true;const z=searchText(p);return wanted.every(f=>z.includes(n(f))||Object.entries(p).some(([k,v])=>n(k).includes(n(f))&&(v===true||String(v).toLowerCase()==="true"||n(v).includes(n(f)))))} async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string,cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){if(!GEMINI||!cfg.ai_rerank_enabled)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. @@ -371,7 +371,7 @@ Deno.serve(async(req:Request)=>{ if(decisionLandmark){for(const p of decisionRows){const d=distanceToLandmark(p,decisionLandmark);if(d!==undefined)p._distanceKm=d;else if(searchText(p).includes(n(decisionLandmark.key)))p._nearbyAreaMatch=true}} let decision=decisionRows.filter(p=>!shownIds.has(String(p.id))); if(decisionLandmark)decision=decision.filter(p=>n(p.city||"")===n(decisionLandmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||p._nearbyAreaMatch===true)); - if(u.budgetMax!==null||u.budgetMin!==null)decision=decision.filter(p=>{const v=budgetValue(p);return v!==undefined&&(u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); + if(u.budgetMax!==null||u.budgetMin!==null)decision=decision.filter(p=>{const v=budgetValue(p,p._type||c);return v!==undefined&&(u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); const rankedDecision=decision.map(p=>({...p,_score:rank(p,combined,ct,near)})).sort((a,z)=>z._score-a._score).slice(0,cfg.max_results).map(card); const ar=await aiProperty(msg,history,rankedDecision,c||"hostel",near,cfg,requestDeadlineAt); const reply=ar.reply||"Aapki requirement ke hisaab se live options compare kar sakte hain."; @@ -454,7 +454,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(wantedLocality)rows=rows.filter(p=>searchText(p).includes(wantedLocality)); // An explicit road/locality is also a hard constraint when the user asked for it. if(locationForFilter.area)rows=rows.filter(p=>searchText(p).includes(n(locationForFilter.area))); - if(u.budgetMax!==null||u.budgetMin!==null)rows=rows.filter(p=>{const v=budgetValue(p);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); + if(u.budgetMax!==null||u.budgetMin!==null)rows=rows.filter(p=>{const v=budgetValue(p,p._type||c);if(v===undefined)return false;return (u.budgetMax===null||v<=u.budgetMax)&&(u.budgetMin===null||v>=u.budgetMin)}); if(u.facilities?.length)rows=rows.filter(p=>matchesFacility(p,u.facilities)); const landmark=u.landmark?findLandmark(u.landmark)||LANDMARKS.find(l=>l.key===u.landmark):findLandmark(near||""); if(landmark){for(const p of rows){const d=distanceToLandmark(p,landmark);if(d!==undefined)p._distanceKm=d;else if(searchText(p).includes(n(landmark.key)))p._nearbyAreaMatch=true}rows=rows.filter(p=>n(p.city||"")===n(landmark.city)&&((p._distanceKm!==undefined&&p._distanceKm<=10)||p._nearbyAreaMatch===true))} From e4fe3f5a5e32b22cf43322379ba140ecd55913e4 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:20:42 +0530 Subject: [PATCH 71/95] Add combined hostel+tiffin budget regression case --- ai-chatbot/tests/chatbot-regression.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ai-chatbot/tests/chatbot-regression.mjs b/ai-chatbot/tests/chatbot-regression.mjs index e44f926bc..66a01cd70 100644 --- a/ai-chatbot/tests/chatbot-regression.mjs +++ b/ai-chatbot/tests/chatbot-regression.mjs @@ -11,6 +11,7 @@ const cases = [ { query: 'Kota Talwandi mein budget tiffin', city: 'Kota', category: 'tiffin', forbiddenCategory: 'hostel' }, { query: 'Jhunjhunu me cafe', city: 'Jhunjhunu', category: 'cafe' }, { query: 'Piprali Road hostel under 5000', city: 'Sikar', category: 'hostel', maxBudget: 5000 }, + { query: 'Kota mein ₹15000 me hostel aur tiffin', city: 'Kota', combo: true, maxBudget: 15000 }, { query: 'Mere paas hostel dikhao', expectLocationPrompt: true }, ]; @@ -50,6 +51,17 @@ async function runCase(testCase, index) { if (failures.length) throw new Error(failures.join('; ')); return testCase.query + ' (GPS prompt returned)'; } + if (testCase.combo) { + const plan = data.budgetPlan; + const types = listings.map(item => String(item.type || '').toLowerCase()); + if (data.intent !== 'Combined Budget Plan' || !plan) failures.push('combined budget planner did not return a budgetPlan'); + if (!types.some(type => type.includes('hostel')) || !types.some(type => type.includes('tiffin'))) failures.push('combo must contain both a hostel and a tiffin listing'); + if (Number(plan?.totalEstimated) > testCase.maxBudget) failures.push('combined monthly total exceeds budget'); + if (Number(plan?.totalEstimated) <= 0) failures.push('combined monthly total is missing/invalid'); + if (listings.some(item => String(item.city || '').toLowerCase() !== testCase.city.toLowerCase())) failures.push('combo contains a listing from another city'); + if (failures.length) throw new Error(failures.join('; ')); + return testCase.query + ' (combo total ₹' + Number(plan.totalEstimated).toLocaleString('en-IN') + ')'; + } if (!city.includes(testCase.city.toLowerCase())) failures.push('expected target city ' + testCase.city + ', received ' + (city || 'none')); if (testCase.category && !category.includes(testCase.category)) failures.push('expected category ' + testCase.category + ', received ' + (category || 'none')); From 3bc180c85b7e95e99e83775af9fee4df81b258a7 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:21:02 +0530 Subject: [PATCH 72/95] Exclude zero and missing prices from budget search results --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 21102674a..16d527bf0 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -201,7 +201,7 @@ function mergeIntent(ai:any,msg:string,history:any[]){ if(o.categories.length)o.category=o.categories[0]; return o; } -function budgetValue(p:any,type?:string){return num(type==="tiffin"?(p.monthly_plan??p.monthly_fee):(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price))} +function budgetValue(p:any,type?:string){const value=num(type==="tiffin"?(p.monthly_plan??p.monthly_fee):(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price));return value!==undefined&&value>0?value:undefined} function matchesFacility(p:any,wanted:string[]){if(!wanted.length)return true;const z=searchText(p);return wanted.every(f=>z.includes(n(f))||Object.entries(p).some(([k,v])=>n(k).includes(n(f))&&(v===true||String(v).toLowerCase()==="true"||n(v).includes(n(f)))))} async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string,cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){if(!GEMINI||!cfg.ai_rerank_enabled)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. From 651aa4ce667f604f75e09e16d050fb798fb81b64 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:21:14 +0530 Subject: [PATCH 73/95] Allow honest no-result response for unpriced Piprali hostel budget search --- ai-chatbot/tests/chatbot-regression.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-chatbot/tests/chatbot-regression.mjs b/ai-chatbot/tests/chatbot-regression.mjs index 66a01cd70..a61f4a1bd 100644 --- a/ai-chatbot/tests/chatbot-regression.mjs +++ b/ai-chatbot/tests/chatbot-regression.mjs @@ -10,7 +10,7 @@ const cases = [ { query: 'Sikar mein Allen ke paas boys hostel', city: 'Sikar', category: 'hostel', forbiddenCity: 'Kota' }, { query: 'Kota Talwandi mein budget tiffin', city: 'Kota', category: 'tiffin', forbiddenCategory: 'hostel' }, { query: 'Jhunjhunu me cafe', city: 'Jhunjhunu', category: 'cafe' }, - { query: 'Piprali Road hostel under 5000', city: 'Sikar', category: 'hostel', maxBudget: 5000 }, + { query: 'Piprali Road hostel under 5000', city: 'Sikar', category: 'hostel', maxBudget: 5000, allowNoResults: true }, { query: 'Kota mein ₹15000 me hostel aur tiffin', city: 'Kota', combo: true, maxBudget: 15000 }, { query: 'Mere paas hostel dikhao', expectLocationPrompt: true }, ]; @@ -65,7 +65,7 @@ async function runCase(testCase, index) { if (!city.includes(testCase.city.toLowerCase())) failures.push('expected target city ' + testCase.city + ', received ' + (city || 'none')); if (testCase.category && !category.includes(testCase.category)) failures.push('expected category ' + testCase.category + ', received ' + (category || 'none')); - if (!Array.isArray(listings) || listings.length === 0) failures.push('no live listing returned'); + if (!Array.isArray(listings) || (listings.length === 0 && !testCase.allowNoResults)) failures.push('no live listing returned'); for (const listing of listings) { const listingCity = String(listing.city || '').toLowerCase(); const listingCategory = String(listing.type || listing.category || '').toLowerCase(); From 3efbd01463d9039246ece04fd6af2cf0b0ebbeb3 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:21:40 +0530 Subject: [PATCH 74/95] Validate trusted Cloudflare IP header before using it for rate limits --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 16d527bf0..8b93c25ce 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -10,7 +10,7 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim()||"";const forwarded=req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()||"";const userAgent=(req.headers.get("user-agent")||"unknown-agent").slice(0,80);const stableSession=(req.headers.get("x-session-id")||sessionId||"anonymous").slice(0,160);const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100){keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60})}else{console.warn("[RATE_LIMIT] trusted cf-connecting-ip missing; using untrusted proxy/session fallback");if(forwarded&&forwarded.length<=100)keys.push({scopeKey:"proxy-untrusted:"+forwarded,limit:8,windowSeconds:60});keys.push({scopeKey:"fallback-session-ua:"+stableSession+":"+userAgent,limit:10,windowSeconds:60})}keys.push({scopeKey:"session:"+stableSession,limit:15,windowSeconds:60});return keys} +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim()||"";const forwarded=req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()||"";const userAgent=(req.headers.get("user-agent")||"unknown-agent").slice(0,80);const stableSession=(req.headers.get("x-session-id")||sessionId||"anonymous").slice(0,160);const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100&&!/[\\s,]/.test(cfIp)){keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60})}else{console.warn("[RATE_LIMIT] trusted cf-connecting-ip missing; using untrusted proxy/session fallback");if(forwarded&&forwarded.length<=100)keys.push({scopeKey:"proxy-untrusted:"+forwarded,limit:8,windowSeconds:60});keys.push({scopeKey:"fallback-session-ua:"+stableSession+":"+userAgent,limit:10,windowSeconds:60})}keys.push({scopeKey:"session:"+stableSession,limit:15,windowSeconds:60});return keys} async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds}),signal:AbortSignal.timeout(4000)});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"},signal:AbortSignal.timeout(4000)});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} From 8bb7de367699853a6e3469b91106cdd202f90e01 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:21:54 +0530 Subject: [PATCH 75/95] Correct whitespace validation for Cloudflare client IP --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 8b93c25ce..fa9b6e776 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -10,7 +10,7 @@ const H={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"autho const SRC=[["hostels","hostel"],["tiffins","tiffin"],["libraries","library"],["cafes","cafe"],["bookstores","bookstore"]] as const; const n=(v:any)=>String(v??"").toLowerCase().normalize("NFKC").replace(/[^a-z0-9\u0900-\u097f]+/gi," ").replace(/\s+/g," ").trim(); const num=(v:any)=>{const x=Number(String(v??"").replace(/,/g,""));return Number.isFinite(x)?x:undefined}; -function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim()||"";const forwarded=req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()||"";const userAgent=(req.headers.get("user-agent")||"unknown-agent").slice(0,80);const stableSession=(req.headers.get("x-session-id")||sessionId||"anonymous").slice(0,160);const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100&&!/[\\s,]/.test(cfIp)){keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60})}else{console.warn("[RATE_LIMIT] trusted cf-connecting-ip missing; using untrusted proxy/session fallback");if(forwarded&&forwarded.length<=100)keys.push({scopeKey:"proxy-untrusted:"+forwarded,limit:8,windowSeconds:60});keys.push({scopeKey:"fallback-session-ua:"+stableSession+":"+userAgent,limit:10,windowSeconds:60})}keys.push({scopeKey:"session:"+stableSession,limit:15,windowSeconds:60});return keys} +function rateKeys(req:Request,sessionId:string){const cfIp=req.headers.get("cf-connecting-ip")?.trim()||"";const forwarded=req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()||"";const userAgent=(req.headers.get("user-agent")||"unknown-agent").slice(0,80);const stableSession=(req.headers.get("x-session-id")||sessionId||"anonymous").slice(0,160);const keys:{scopeKey:string;limit:number;windowSeconds:number}[]=[];if(cfIp&&cfIp.length<=100&&!/[\s,]/.test(cfIp)){keys.push({scopeKey:"ip:"+cfIp,limit:30,windowSeconds:60})}else{console.warn("[RATE_LIMIT] trusted cf-connecting-ip missing; using untrusted proxy/session fallback");if(forwarded&&forwarded.length<=100)keys.push({scopeKey:"proxy-untrusted:"+forwarded,limit:8,windowSeconds:60});keys.push({scopeKey:"fallback-session-ua:"+stableSession+":"+userAgent,limit:10,windowSeconds:60})}keys.push({scopeKey:"session:"+stableSession,limit:15,windowSeconds:60});return keys} async function consumeDualRateLimit(scope:string,req:Request,sessionId:string,limit:number,windowSeconds:number){for(const item of rateKeys(req,sessionId)){const allowed=await consumeRateLimit(scope,item.scopeKey,Math.min(limit,item.limit),Math.max(windowSeconds,item.windowSeconds));if(!allowed)return false}return true} async function consumeRateLimit(scope:string,key:string,limit:number,windowSeconds:number){if(!U||!K)throw Error("rate limiter database configuration missing");const r=await fetch(U+"/rest/v1/rpc/consume_chatbot_rate_limit",{method:"POST",headers:{apikey:K,Authorization:"Bearer "+K,"Content-Type":"application/json"},body:JSON.stringify({p_scope:scope,p_key:key,p_limit:limit,p_window_seconds:windowSeconds}),signal:AbortSignal.timeout(4000)});if(!r.ok)throw Error("distributed rate limiter unavailable: "+r.status);return await r.json()===true} async function db(path:string):Promise{if(!U||!K)throw Error("database configuration missing");const r=await fetch(U+path,{headers:{apikey:K,Authorization:`Bearer ${K}`,"Content-Type":"application/json"},signal:AbortSignal.timeout(4000)});if(!r.ok)throw Error(`database ${r.status}`);return await r.json() as T} From ee9d8b9cc4be4c4f93cbebbed839a7922a48cade Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:22:03 +0530 Subject: [PATCH 76/95] Reject out-of-range GPS coordinates before distance calculations --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index fa9b6e776..244520968 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -25,7 +25,7 @@ function findLandmark(s:string){const x=n(normalizeSearchTerms(s));for(const l o function nearTerm(s:string){const x=n(normalizeSearchTerms(s)),lm=findLandmark(x);if(lm)return lm.key;const known=["vibrant coaching","vibrant career institute","nawalgarh road","nawalgarh rd","nawalgarh","clc","allen","gurukripa","matrix","matrix academy","prayas eduhub","banco career academy","inspector ssc academy"];const hit=known.find(k=>x.includes(n(k)));if(hit)return hit;const m=x.match(/(?:near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)(?:\s+me)?\s+([a-z0-9\u0900-\u097f][a-z0-9\u0900-\u097f ]{1,50}?)(?=\s+(?:chahiye|dikhao|dikhado|hai|ho|rehna|rahna|book|konsa|kaunsa|aur|bhi)\b|$)/i);if(m)return m[1].trim();if(/(?:piprali road|piprali rd|पिपराली रोड)/i.test(x))return "piprali road";return ""} function cat(s:string){const x=n(s),hits:[string,number][]=[];const defs:[string,RegExp][]=[["hostel",/hostel|pg|room|stay|accommodation|girls|boys|female|male|rehne|rehna|रहने|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food|meal|jain food|टिफिन|मेस|खाना/],["library",/library|reading|seat|लाइब्रेरी/],["cafe",/cafe|coffee|restaurant|hangout|कैफे/],["bookstore",/bookstore|books|stationery|bookshop|किताब|बुकस्टोर/]];for(const [type,re] of defs)for(const m of x.matchAll(new RegExp(re.source,"gi")))hits.push([type,m.index??-1]);const near=new Set(hits.filter(([,pos])=>/(near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)/i.test(x.slice(Math.max(0,pos-50),pos))).map(([type])=>type));return hits.find(([type])=>!near.has(type))?.[0]||hits[0]?.[0]} function secondaryCats(s:string){const x=n(s),out:string[]=[];const defs:[string,RegExp][]=[["hostel",/hostel|pg|room|stay|accommodation|girls|boys|female|male|rehne|rehna|रहने|हॉस्टल|पीजी/],["tiffin",/tiffin|mess|khana|food|meal|टिफिन|मेस|खाना/],["library",/library|reading|seat|लाइब्रेरी/],["cafe",/cafe|coffee|restaurant|hangout|कैफे/],["bookstore",/bookstore|books|stationery|bookshop|किताब|बुकस्टोर/]];for(const [type,re] of defs)for(const m of x.matchAll(new RegExp(re.source,"gi"))){const pos=m.index??-1;if(/(near|nearby|around|paas|pass|ke paas|k pass|aas paas|bagal|nazdeek|पास|आस पास|बगल)/i.test(x.slice(Math.max(0,pos-50),pos)))out.push(type)}return [...new Set(out)]} -function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude??p.latitude_deg),lng=Number(p.longitude??p.lng??p.lon??p.location_longitude??p.longitude_deg);return Number.isFinite(lat)&&Number.isFinite(lng)?{lat,lng}:null} +function coords(p:any){const lat=Number(p.latitude??p.lat??p.location_latitude??p.latitude_deg),lng=Number(p.longitude??p.lng??p.lon??p.location_longitude??p.longitude_deg);return Number.isFinite(lat)&&Number.isFinite(lng)&&lat>=-90&&lat<=90&&lng>=-180&&lng<=180?{lat,lng}:null} function distanceKm(a:any,b:any){const A=coords(a),B=coords(b);if(!A||!B)return undefined;const r=(v:number)=>v*Math.PI/180,dLat=r(B.lat-A.lat),dLon=r(B.lng-A.lng),h=Math.sin(dLat/2)**2+Math.cos(r(A.lat))*Math.cos(r(B.lat))*Math.sin(dLon/2)**2;return 6371*2*Math.atan2(Math.sqrt(h),Math.sqrt(1-h))} function distanceToLandmark(p:any,l:any){const A=coords(p),B=l?{latitude:l.lat,longitude:l.lng}:null;if(!A||!B)return undefined;return distanceKm(A,B)} function searchText(p:any){return n([p.name,p.title,p.business_name,p.service_name,p.type,p.gender_type,p.gender,p.city,p.area,p.service_area,p.address,p.landmark,p.nearby_coaching,p.description,p.facilities,p.amenities].filter(Boolean).join(" "))} From 12af371571b82530fdf17388b0802e3d6a401051 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:22:15 +0530 Subject: [PATCH 77/95] Bound total cursor pagination time per category search --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 244520968..9eee3e13a 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -48,7 +48,7 @@ async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:str } if(orGroups.length===1)params.set("or",`(${orGroups[0]})`); else if(orGroups.length>1)params.set("and",`(${orGroups.map(group=>`or(${group})`).join(",")})`); - const page:any[]=[];let cursor:string|number|undefined;while(page.length(`/rest/v1/${table}?${params.toString()}`);const batch=Array.isArray(rows)?rows:[];if(!batch.length)break;page.push(...batch);const lastId=batch[batch.length-1]?.id;if(lastId===undefined||batch.length(`/rest/v1/${table}?${params.toString()}`);const batch=Array.isArray(rows)?rows:[];if(!batch.length)break;page.push(...batch);const lastId=batch[batch.length-1]?.id;if(lastId===undefined||batch.length=pagingDeadline)console.warn(`[SEARCH] ${type} cursor pagination reached its 8s budget; returning bounded partial results`); // Keyset pagination on the category primary key; filters run in Postgres before each bounded page. return page.map(p=>({...p,_type:type})).filter(p=>!ct||n(p.city||"").includes(n(ct))); } From a4f8c86d2c1591c416ea8638c3ff08472cd371b7 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:22:30 +0530 Subject: [PATCH 78/95] Prioritize explicit current-message category over AI category guesses --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 9eee3e13a..039d9f925 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -349,7 +349,7 @@ Deno.serve(async(req:Request)=>{ } if(isAccountSupport(msg))return await chatResponse({reply:accountSupportReply(msg),intent:"Account / Login Support",primaryTopic:"Account access",suggestedFollowUps:["I forgot my password","Google login is not working","Dashboard is not opening","Contact support"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); if(isContact(msg)||String(b?.action||"")==="contact_support")return await chatResponse({reply:"Ji bilkul 😊 StudentHubHelp team se contact ke liye:\n\n📧 Email: studenthubhelpp@gmail.com\n📞 Phone: +91 9929718264",intent:"Support / Contact",primaryTopic:"Contact StudentHubHelp",suggestedFollowUps:["Find a hostel","Find a library","Search by area","Back to search"],recommendations:[],recommendedProperties:[],grounded:true,sessionId},{headers:H},sessionId,msg); - const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),multiCategoryRequest=/(?:\b(?:and|plus|with)\b|\+|\baur\b|और)/i.test(n(msg)),detectedCats=["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\bpg\\b|हॉस्टल|पीजी":type==="tiffin"?"tiffin|mess|meal|टिफिन|मेस":type==="library"?"library|reading room|लाइब्रेरी":type==="cafe"?"cafe|कैफे":"bookstore|book shop|stationery|बुकस्टोर","i").test(n(msg))),aiCats=explicitCat&&!multiCategoryRequest?[explicitCat]:[...new Set([...(u.categories||[]),...detectedCats])],c=u.category||explicitCat||(u.followUp?cat(previous):""),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); + const previous=history.filter(x=>x.role==="user").map(x=>x.text||"").join(" "),combined=previous+" "+msg,aiRaw=await aiUnderstand(msg,history,cfg,requestDeadlineAt),u=mergeIntent(aiRaw,msg,history),explicitCat=cat(msg),nearCats=secondaryCats(msg),multiCategoryRequest=/(?:\b(?:and|plus|with)\b|\+|\baur\b|और)/i.test(n(msg)),detectedCats=["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\bpg\\b|हॉस्टल|पीजी":type==="tiffin"?"tiffin|mess|meal|टिफिन|मेस":type==="library"?"library|reading room|लाइब्रेरी":type==="cafe"?"cafe|कैफे":"bookstore|book shop|stationery|बुकस्टोर","i").test(n(msg))),aiCats=explicitCat&&!multiCategoryRequest?[explicitCat]:[...new Set([...(u.categories||[]),...detectedCats])],c=explicitCat&&!multiCategoryRequest?explicitCat:(u.category||explicitCat||(u.followUp?cat(previous):"")),propertyMode=u.intent.startsWith("property_")||isPropertyQuery(msg,u.followUp?previous:""),ct=u.city||(u.followUp?city(previous):""),near=u.landmark||u.area||u.locality||(u.nearRelation?nearTerm(msg):""),locationForFilter=strictLocation(msg),action=String(b?.action||u.action||""),shownList=(Array.isArray(b?.shownPropertyIds)?b.shownPropertyIds:[]).map((x:any)=>String(x)),shownIds=new Set(shownList); const clientCoords=coords(b?.clientCoords); const genericNearbyIntent=/(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(n(msg))&&!u.landmark&&!u.area&&!u.locality&&!nearTerm(msg); if(propertyMode&&genericNearbyIntent&&!clientCoords)return await chatResponse({reply:"Aapke aas-paas listings dikhane ke liye device location ki zaroorat hai. Kripya location access allow karein ya apna landmark/area batayein (jaise Piprali Road ya Allen ke paas).",intent:"Location Required",primaryTopic:"Nearby listing search",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Search by area","Search by city"],grounded:true,sessionId},{headers:H},sessionId,msg); From e12534997fe4dedf97b143da9454223031a4d268 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:23:12 +0530 Subject: [PATCH 79/95] Use first positive verified monthly price across fallback fields --- supabase/functions/studenthubhelp-chat/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 039d9f925..781a5f3dc 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -37,8 +37,8 @@ async function direct(type:string,ct?:string,maxBudget?:number,locationTerm?:str if(maxBudget!==undefined&&Number.isFinite(maxBudget)&&maxBudget>0){ const budget=String(Math.floor(maxBudget)); orGroups.push(type==="hostel" - ? `monthly_rent.lte.${budget},and(monthly_rent.is.null,monthly_fee.lte.${budget}),and(monthly_rent.is.null,monthly_fee.is.null,price.lte.${budget})` - : type==="tiffin" ? `monthly_plan.lte.${budget},and(monthly_plan.is.null,monthly_fee.lte.${budget})` : `monthly_fee.lte.${budget},and(monthly_fee.is.null,price.lte.${budget})`); + ? `monthly_rent.lte.${budget},monthly_fee.lte.${budget},price.lte.${budget}` + : type==="tiffin" ? `monthly_plan.lte.${budget},monthly_fee.lte.${budget}` : `monthly_fee.lte.${budget},price.lte.${budget}`); } const term=String(locationTerm||"").replace(/[^a-zA-Z0-9\u0900-\u097f -]/g," ").replace(/\s+/g," ").trim(); if(term)orGroups.push(["name","area","address","landmark","nearby_coaching","description"].map(col=>`${col}.ilike.*${term}*`).join(",")); @@ -57,7 +57,8 @@ async function multiDirect(types:string[],ct?:string,maxBudget?:number,locationT const groups=await Promise.all(unique.map(type=>direct(type,ct,maxBudget,locationTerm,genderFilter))); return groups.flat(); } -function monthlyAmount(p:any,type:string){const value=Number(type==="hostel"?(p.monthly_rent??p.monthly_fee??p.price):(p.monthly_plan??p.monthly_fee));return Number.isFinite(value)&&value>0?value:undefined} +function positiveAmount(...values:any[]){for(const raw of values){const value=num(raw);if(value!==undefined&&value>0)return value}return undefined} +function monthlyAmount(p:any,type:string){return type==="hostel"?positiveAmount(p.monthly_rent,p.monthly_fee,p.price):positiveAmount(p.monthly_plan,p.monthly_fee)} async function planComboBudget(cityName:string,totalBudget:number,genderFilter?:string){ const hostelCap=Math.floor(totalBudget*0.70),tiffinCap=Math.floor(totalBudget*0.35); const [hostelRows,tiffinRows]=await Promise.all([direct("hostel",cityName,hostelCap,undefined,genderFilter),direct("tiffin",cityName,tiffinCap)]); @@ -70,7 +71,7 @@ async function planComboBudget(cityName:string,totalBudget:number,genderFilter?: } function genderWanted(s:string){const x=n(normalizeSearchTerms(s));if(/\b(girls|girl|female|ladki|ladkiyo|ladkiyon|महिला|लड़क|लड़की|girls hostel)\b/i.test(x))return "girls";if(/\b(boys|boy|male|ladke|ladkon|लड़के|लड़का|boys hostel)\b/i.test(x))return "boys";return ""} function areaWanted(s:string){const x=n(normalizeSearchTerms(s));if(/nawalgarh road|nawalgarh rd|नवलगढ़ रोड/i.test(x))return "nawalgarh";if(/piprali road|piprali rd|पिपराली रोड/i.test(x))return "piprali";return ""} -function priceLabel(p:any,c:string){const v=Number(c==="tiffin"?(p.monthly_plan??p.monthly_fee):(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price));if(!Number.isFinite(v)||v<=0)return "";if(c==="hostel"||c==="tiffin"||c==="library")return "₹"+v.toLocaleString("en-IN")+"/mo";return "₹"+v.toLocaleString("en-IN")} +function priceLabel(p:any,c:string){const v=c==="tiffin"?positiveAmount(p.monthly_plan,p.monthly_fee):positiveAmount(p.monthly_rent,p.monthly_fee,p.monthly_charge,p.price);if(v===undefined)return "";if(c==="hostel"||c==="tiffin"||c==="library")return "₹"+v.toLocaleString("en-IN")+"/mo";return "₹"+v.toLocaleString("en-IN")} function card(p:any){const c=p._type,price=priceLabel(p,c),d=p._distanceKm;return{id:String(p.id),name:p.name||p.title||p.service_name||p.business_name||"Active listing",type:c,city:p.city||"",area:p.area||p.service_area||"",address:p.address||"",phone:([p.phone,p.contact_phone,p.whatsapp,p.whatsapp_number].find(v=>{const s=String(v??"").trim();return s&&s!=="0"&&s.replace(/\D/g,"").length>=7})||""),rating:num(p.rating),verified:p.verified===true,price,distanceKm:d===undefined?null:Number(d.toFixed(1)),distanceVerified:d!==undefined,distanceLabel:d===undefined?(p._nearbyAreaMatch?`${p.area||p.service_area||p.city||"Area"} area match · exact distance unavailable`:""):`~${d.toFixed(1)} km (GPS straight-line)`,nearbyMatch:p._nearbyVerified===true||p._nearbyAreaMatch===true,highlights:["wifi","ac_available","ac","cctv","food_available","mess_available","available_beds","room_types","room_sharing","gender_type","timing","timings","delivery_available","nearby_coaching"].map(k=>[k,p[k]]).filter(([,v])=>v!==null&&v!==undefined&&String(v).trim()!==""&&String(v).toLowerCase()!=="false").slice(0,4).map(([k,v])=>k+": "+String(v)),description:p.description&&String(p.description).trim().length<=280?String(p.description).trim():"",mapsUrl:p.google_maps_url||"",detailsUrl:p.slug?`property-details.html?type=${c}&slug=${encodeURIComponent(p.slug)}`:undefined}} function rank(p:any,q:string,ct:string,near:string){const x=searchText(p),t=n(q);let s=0;if(p._distanceKm!==undefined)s+=Math.max(0,25-p._distanceKm*5);const a=areaWanted(q);if(a&&x.includes(a))s+=25;if(ct&&n(p.city||"").includes(n(ct)))s+=30;for(const w of t.split(" ").filter(z=>z.length>2).slice(0,20))if(x.includes(w))s+=2;if(near){if(near==="allen"||near==="clc"){if(p._distanceKm!==undefined)s+=Math.max(0,20-p._distanceKm*5);if(x.includes(near))s+=10}else if(near==="vibrant"){if(x.includes("vibrant"))s+=20;if(x.includes("nawalgarh"))s+=8}else if(near==="matrix"||near==="gurukripa"){if(x.includes(near))s+=30;else s-=3}else if(x.includes(n(near)))s+=20}if(p.verified===true)s+=4;if(Number.isFinite(Number(p.rating)))s+=Math.min(5,Number(p.rating));return s} function isGreeting(s:string){return /^(hi|hello|hey|namaste|good morning|good afternoon|good evening|good night|gm|gn|hii|hlo|नमस्ते|सुप्रभात|शुभ प्रभात|शुभ संध्या)[!.\s]*$/i.test(s.trim())} @@ -201,7 +202,7 @@ function mergeIntent(ai:any,msg:string,history:any[]){ if(o.categories.length)o.category=o.categories[0]; return o; } -function budgetValue(p:any,type?:string){const value=num(type==="tiffin"?(p.monthly_plan??p.monthly_fee):(p.monthly_rent??p.monthly_fee??p.monthly_charge??p.price));return value!==undefined&&value>0?value:undefined} +function budgetValue(p:any,type?:string){return type==="tiffin"?positiveAmount(p.monthly_plan,p.monthly_fee):positiveAmount(p.monthly_rent,p.monthly_fee,p.monthly_charge,p.price)} function matchesFacility(p:any,wanted:string[]){if(!wanted.length)return true;const z=searchText(p);return wanted.every(f=>z.includes(n(f))||Object.entries(p).some(([k,v])=>n(k).includes(n(f))&&(v===true||String(v).toLowerCase()==="true"||n(v).includes(n(f)))))} async function aiProperty(msg:string,history:any[],recs:any[],c?:string,near?:string,cfg:ChatbotRuntimeConfig=DEFAULT_RUNTIME_CONFIG,deadlineAt?:number){if(!GEMINI||!cfg.ai_rerank_enabled)return{};try{const h=history.slice(-8).map(x=>`${x.role==="user"?"USER":"ASSISTANT"}: ${String(x.text||"")}`).join("\n");const prompt=`You are StudentHubHelp's Ultra Advance AI Assistant. From 7dd21109ad95a53dde974cef7108ec27c499e631 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:23:35 +0530 Subject: [PATCH 80/95] Explain missing monthly-price data when no budget combo is verifiable --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 781a5f3dc..9f3a9e925 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -438,7 +438,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(cfg.budget_planner_enabled&&budgetFromText>0&&comboCategories.includes("hostel")&&comboCategories.includes("tiffin")){ if(!ct)return await chatResponse({reply:"Hostel + tiffin ka exact monthly combo banane ke liye apni city bata dijiye.",intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Sikar mein ₹8000 budget","Kota mein ₹8000 budget","Jaipur mein ₹8000 budget"],grounded:true,sessionId},{headers:H},sessionId,msg); const combo=await planComboBudget(ct,budgetFromText,u.gender||genderWanted(msg)); - if(!combo)return await chatResponse({reply:`${ct} mein ₹${budgetFromText.toLocaleString("en-IN")} ke andar verified monthly price ke saath hostel + tiffin ka complete combo abhi nahi mila. Aap budget badha sakte hain ya dono categories alag-alag search kar sakte hain.`,intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Hostel alag search karo","Tiffin alag search karo","Budget badhakar try karo"],grounded:true,sessionId},{headers:H},sessionId,msg); + if(!combo)return await chatResponse({reply:`${ct} mein ₹${budgetFromText.toLocaleString("en-IN")} ke andar dono categories ke verified monthly prices wala combo nahi mila. Kuch listings mein monthly rent/plan missing hai, isliye main unka total guess nahi karunga. Aap budget badha sakte hain ya hostel aur tiffin alag-alag search kar sakte hain.`,intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Hostel alag search karo","Tiffin alag search karo","Budget badhakar try karo"],grounded:true,sessionId},{headers:H},sessionId,msg); const hostelCard=card(combo.hostel),tiffinCard=card(combo.tiffin),total=combo.total,savings=budgetFromText-total; const budgetPlan={targetBudget:budgetFromText,city:ct,livingType:"pg_tiffin" as const,items:[{category:"rent" as const,label:`${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month`,amount:combo.hostelCost,note:[hostelCard.area,hostelCard.city].filter(Boolean).join(", ")},{category:"food" as const,label:`${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month`,amount:combo.tiffinCost,note:[tiffinCard.area,tiffinCard.city].filter(Boolean).join(", ")}],totalEstimated:total,savingsOrOverrun:savings,status:savings>300?"under_budget" as const:savings< -300?"over_budget" as const:"balanced" as const,savingsTips:["Prices are taken from active StudentHubHelp listings; confirm inclusions and availability with the provider before payment."],recommendedCombo:`${hostelCard.name} + ${tiffinCard.name}`}; const reply=`Aapke ₹${budgetFromText.toLocaleString("en-IN")} monthly budget mein ${ct} ka best available combo:\n\n🏠 **Hostel:** ${hostelCard.name} — ₹${combo.hostelCost.toLocaleString("en-IN")}/month\n🍲 **Tiffin:** ${tiffinCard.name} — ₹${combo.tiffinCost.toLocaleString("en-IN")}/month\n\n💰 **Total:** ₹${total.toLocaleString("en-IN")}/month\n**Bachat:** ₹${savings.toLocaleString("en-IN")}/month\n\nYeh prices active listing records se li gayi hain; final inclusions aur availability provider se confirm karein.`; From 1b18713ed965e08aef3a76436eae4fa807a0fc53 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:23:47 +0530 Subject: [PATCH 81/95] Parse comma-formatted rupee budgets in combo planner --- supabase/functions/studenthubhelp-chat/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/studenthubhelp-chat/index.ts b/supabase/functions/studenthubhelp-chat/index.ts index 9f3a9e925..37a87c362 100644 --- a/supabase/functions/studenthubhelp-chat/index.ts +++ b/supabase/functions/studenthubhelp-chat/index.ts @@ -434,7 +434,7 @@ const ownerDetailsIntent=/(?:owner|malik|maalik|proprietor|manager).{0,35}(?:det if(isBookingInfoQuestion(msg)){const ar=await aiGeneral(`The user asks how to book/reserve a hostel or PG. Explain a practical StudentHubHelp booking process: shortlist suitable live listings, open full details, check price/room type/availability, call owner, confirm terms and visit/verify before paying. Do not invent any property-specific availability or booking facility.`,history,cfg,requestDeadlineAt);const reply=ar.reply||"Hostel book karne ka simple process: pehle suitable listing shortlist karein, full details me price/room type check karein, owner ko Call karke availability aur terms confirm karein, aur payment se pehle property/owner verify karein.";return await chatResponse({reply,intent:"Booking Guidance",primaryTopic:"Hostel / PG Booking",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Allen ke paas hostel dikhao","Budget ke according hostel dikhao","Boys hostel dikhao","Girls hostel dikhao"],grounded:true,sessionId},{headers:H},sessionId,msg);} if(!propertyMode){const ar=await aiGeneral(msg,history,cfg,requestDeadlineAt);const reply=ar.reply||"Bilkul 😊 Main yahin hoon. Aap jo poochna chahein, seedha poochiye—study, ideas, facts ya normal conversation, sab par baat kar sakte hain.";return await chatResponse({reply,intent:"General / Study / Conversation",primaryTopic:"Student AI Assistant",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Ask a study question","Tell me something interesting","Find a property","Contact support"],grounded:true,webGrounded:ar.webGrounded||false,webSources:ar.webSources||[],sessionId},{headers:H},sessionId,msg);} const comboCategories=[...new Set([...(c?[c]:[]),...aiCats,...nearCats,...(["hostel","tiffin","library","cafe","bookstore"].filter(type=>new RegExp(type==="hostel"?"hostel|\\bpg\\b":type==="tiffin"?"tiffin|mess":type==="library"?"library":type==="cafe"?"cafe":"bookstore","i").test(n(msg))))])]; - const budgetFromText=Number(u.budgetMax)||Number(msg.match(/(?:₹|rs\.?|inr)?\s*(\d{4,6})/i)?.[1])||0; + const budgetFromText=Number(u.budgetMax)||Number(String(msg.match(/(?:₹|rs\.?|inr)?\s*(\d{1,3}(?:,\d{3})+|\d{4,6})/i)?.[1]||"").replace(/,/g,""))||0; if(cfg.budget_planner_enabled&&budgetFromText>0&&comboCategories.includes("hostel")&&comboCategories.includes("tiffin")){ if(!ct)return await chatResponse({reply:"Hostel + tiffin ka exact monthly combo banane ke liye apni city bata dijiye.",intent:"Budget Planning",primaryTopic:"Hostel + Tiffin Combo Budget",recommendedProperties:[],recommendations:[],suggestedFollowUps:["Sikar mein ₹8000 budget","Kota mein ₹8000 budget","Jaipur mein ₹8000 budget"],grounded:true,sessionId},{headers:H},sessionId,msg); const combo=await planComboBudget(ct,budgetFromText,u.gender||genderWanted(msg)); From 9ac35be2b086ac4fd666e4e929e6f06dfe676b31 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:24:35 +0530 Subject: [PATCH 82/95] Wire budget planner action to a fresh chatbot search --- ai-chatbot/src/components/ChatWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-chatbot/src/components/ChatWidget.tsx b/ai-chatbot/src/components/ChatWidget.tsx index 3e685689b..a22cf1af4 100644 --- a/ai-chatbot/src/components/ChatWidget.tsx +++ b/ai-chatbot/src/components/ChatWidget.tsx @@ -386,7 +386,7 @@ export const ChatWidget: React.FC = ({ {isOpen &&
SH
StudentHubHelp AI
Live listing assistant
Live Supabase data · Smart assistant
{QUICK_TOPICS.map(topic=>)}
-
{recentLeadAlert&&
{recentLeadAlert}
}{messages.map(m=>
{m.sender==='user'?:}{m.sender==='user'?'You':'StudentHubHelp AI'}{m.timestamp}
{m.text}
{m.diagnosis?.primaryTopic&&m.sender==='bot'&&
Topic: {m.diagnosis.primaryTopic}
}{m.recommendedProperties?.length>0&&
{m.recommendedProperties.map(renderPropertyCard)}
}{m.budgetPlan&&
}{m.comparisonMatrix&&
}{m.diagnosis?.suggestedFollowUps?.length>0&&m.sender==='bot'&&
{m.diagnosis.suggestedFollowUps.slice(0,4).map((f:string)=>)}
}
)}{isLoading&&
AI is checking live listings…
}{retryText&&!isLoading&&
}
+
{recentLeadAlert&&
{recentLeadAlert}
}{messages.map(m=>
{m.sender==='user'?:}{m.sender==='user'?'You':'StudentHubHelp AI'}{m.timestamp}
{m.text}
{m.diagnosis?.primaryTopic&&m.sender==='bot'&&
Topic: {m.diagnosis.primaryTopic}
}{m.recommendedProperties?.length>0&&
{m.recommendedProperties.map(renderPropertyCard)}
}{m.budgetPlan&&
void handleSendMessage(query)} />
}{m.comparisonMatrix&&
}{m.diagnosis?.suggestedFollowUps?.length>0&&m.sender==='bot'&&
{m.diagnosis.suggestedFollowUps.slice(0,4).map((f:string)=>)}
}
)}{isLoading&&
AI is checking live listings…
}{retryText&&!isLoading&&
}
setInputText(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type any topic (Hostel, Tiffin, Library, Rates)..." className="min-w-0 flex-1 h-11 rounded-xl border border-slate-200 px-2.5 text-sm outline-none focus:border-amber-400"/>
Live listing support
}
; From 1a456f27bc60637e370f0804202db7d2aabc6d75 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:24:40 +0530 Subject: [PATCH 83/95] Preserve selected city when recalculating hostel and tiffin budget --- ai-chatbot/src/components/BudgetPlannerCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-chatbot/src/components/BudgetPlannerCard.tsx b/ai-chatbot/src/components/BudgetPlannerCard.tsx index b253daea3..2c0ed2ad9 100644 --- a/ai-chatbot/src/components/BudgetPlannerCard.tsx +++ b/ai-chatbot/src/components/BudgetPlannerCard.tsx @@ -285,7 +285,7 @@ export const BudgetPlannerCard: React.FC = ({ type="button" onClick={() => { if (onSendQuery) { - onSendQuery(`Mera monthly budget ₹${sliderBudget} hai, isme best verified hostel aur tiffin combo suggest karo`); + onSendQuery(`Mera monthly budget ₹${sliderBudget} hai, ${initialPlan.city ? initialPlan.city + " mein " : ""}best verified hostel aur tiffin combo suggest karo`); } }} className="flex-1 min-w-[140px] bg-amber-400 hover:bg-amber-300 text-slate-950 font-extrabold text-[11px] py-2 px-3 rounded-xl transition flex items-center justify-center gap-1.5 shadow-md" From c82504b3583c659d9b5e605c16cdf2bbb157af32 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:25:08 +0530 Subject: [PATCH 84/95] Lock production React chatbot to Supabase Edge Function URL --- ai-chatbot/src/components/ChatWidget.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ai-chatbot/src/components/ChatWidget.tsx b/ai-chatbot/src/components/ChatWidget.tsx index a22cf1af4..23379d79c 100644 --- a/ai-chatbot/src/components/ChatWidget.tsx +++ b/ai-chatbot/src/components/ChatWidget.tsx @@ -29,9 +29,9 @@ import { ChatMessage, ChatApiResponse, SentimentType } from '../types'; import { BudgetPlannerCard } from './BudgetPlannerCard'; import { ComparisonMatrixCard } from './ComparisonMatrixCard'; -const CHAT_API_URL = - (import.meta as any).env?.VITE_CHAT_API_URL || - 'https://idurlccrarznnnqixxsd.supabase.co/functions/v1/studenthubhelp-chat'; +const PRODUCTION_CHAT_API = 'https://idurlccrarznnnqixxsd.supabase.co/functions/v1/studenthubhelp-chat'; +const configuredChatApi = (import.meta as any).env?.VITE_CHAT_API_URL || PRODUCTION_CHAT_API; +const CHAT_API_URL = (import.meta as any).env?.PROD ? PRODUCTION_CHAT_API : configuredChatApi; const SUPABASE_ANON_KEY = (import.meta as any).env?.VITE_SUPABASE_ANON_KEY || ''; From 1c2035b0836e240b4c1f026a9756391fca42bf2f Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:25:11 +0530 Subject: [PATCH 85/95] Verify no localhost API references and no Express server production bundle --- .github/workflows/chatbot-pr-check.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index c666e86c3..f17995406 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -87,10 +87,11 @@ jobs: })); } NODE - - name: Validate regression and public widget scripts + - name: Validate regression scripts and production API references run: | node --check tests/chatbot-regression.mjs node --check ../widget.js + if grep -R -nE 'https?://(localhost|127\\.0\\.0\\.1):3000' src; then echo 'Local Express API reference found in client source'; exit 1; fi - name: Type-check React chatbot component run: npx tsc --noEmit --skipLibCheck --jsx react-jsx --target ES2020 --module ESNext --moduleResolution bundler --allowImportingTsExtensions --types vite/client src/components/ChatWidget.tsx - name: Restore Vite source entry for validation @@ -110,4 +111,6 @@ jobs: EOF - name: Build chatbot - run: npm run build + run: | + npm run build + test ! -f dist/server.cjs From 8938619a6059436bf8860dee5129373a6c948ffc Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:25:20 +0530 Subject: [PATCH 86/95] Correct localhost API grep expression --- .github/workflows/chatbot-pr-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index f17995406..73af8d9de 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -91,7 +91,7 @@ jobs: run: | node --check tests/chatbot-regression.mjs node --check ../widget.js - if grep -R -nE 'https?://(localhost|127\\.0\\.0\\.1):3000' src; then echo 'Local Express API reference found in client source'; exit 1; fi + if grep -R -nE 'https?://(localhost|127\.0\.0\.1):3000' src; then echo 'Local Express API reference found in client source'; exit 1; fi - name: Type-check React chatbot component run: npx tsc --noEmit --skipLibCheck --jsx react-jsx --target ES2020 --module ESNext --moduleResolution bundler --allowImportingTsExtensions --types vite/client src/components/ChatWidget.tsx - name: Restore Vite source entry for validation From 878f3a541c92b6ccc8d43a5bb1e8a5bb22def6d3 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:25:44 +0530 Subject: [PATCH 87/95] Require category metadata in search regression assertions --- ai-chatbot/tests/chatbot-regression.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/ai-chatbot/tests/chatbot-regression.mjs b/ai-chatbot/tests/chatbot-regression.mjs index a61f4a1bd..f64fd72d0 100644 --- a/ai-chatbot/tests/chatbot-regression.mjs +++ b/ai-chatbot/tests/chatbot-regression.mjs @@ -69,6 +69,7 @@ async function runCase(testCase, index) { for (const listing of listings) { const listingCity = String(listing.city || '').toLowerCase(); const listingCategory = String(listing.type || listing.category || '').toLowerCase(); + if (!listingCategory) failures.push('listing category missing: ' + (listing.name || listing.id)); if (!listingCity) failures.push('listing city missing: ' + (listing.name || listing.id)); if (listingCity && listingCity !== testCase.city.toLowerCase()) failures.push('cross-city listing leaked: ' + listing.city); if (testCase.forbiddenCity && listingCity.includes(testCase.forbiddenCity.toLowerCase())) failures.push('forbidden city leaked: ' + listing.city); From a3039b6ceacdf76f15d4ddfc4e424d8026657e45 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:25:58 +0530 Subject: [PATCH 88/95] Clean stale Express bundle before static production build --- ai-chatbot/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-chatbot/package.json b/ai-chatbot/package.json index ff5c126dd..5c195ced4 100644 --- a/ai-chatbot/package.json +++ b/ai-chatbot/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "npm run clean && vite build", "start": "vite preview --host 0.0.0.0", "preview": "vite preview", "clean": "rm -rf dist server.js", From 34c02363515e6019a5b5dbba54179d7b5cc65227 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:26:14 +0530 Subject: [PATCH 89/95] Fix embed generator to point at Supabase chatbot Edge Function --- ai-chatbot/src/components/EmbedCodeModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ai-chatbot/src/components/EmbedCodeModal.tsx b/ai-chatbot/src/components/EmbedCodeModal.tsx index 450599e22..f07d83528 100644 --- a/ai-chatbot/src/components/EmbedCodeModal.tsx +++ b/ai-chatbot/src/components/EmbedCodeModal.tsx @@ -25,8 +25,9 @@ export const EmbedCodeModal: React.FC = ({ isOpen, onClose if (!isOpen) return null; - const currentHost = typeof window !== 'undefined' ? window.location.origin : 'https://studenthubhelp.github.io'; - const embedScript = ``; + const currentHost = typeof window !== 'undefined' ? window.location.origin : 'https://studenthubhelp.in'; + const chatApiUrl = 'https://idurlccrarznnnqixxsd.supabase.co/functions/v1/studenthubhelp-chat'; + const embedScript = ``; const handleCopy = () => { navigator.clipboard.writeText(embedScript); From c1f2f3b33819e09b597748d5d1f9995d7aaa089f Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:27:29 +0530 Subject: [PATCH 90/95] Add optional GPS coordinates to bundled public chatbot widget --- ai-chatbot/public/widget.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ai-chatbot/public/widget.js b/ai-chatbot/public/widget.js index f7a00be53..aea20d96e 100644 --- a/ai-chatbot/public/widget.js +++ b/ai-chatbot/public/widget.js @@ -18,6 +18,8 @@ const SHH_WIDGET=(()=>{ let history=[],speaking=false,busy=false,expanded=false,lastData=null;let sessionId=(window.crypto&&crypto.randomUUID)?crypto.randomUUID():'chat-'+Date.now(); const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const safeUrl=v=>{try{const u=new URL(String(v));return ['http:','https:'].includes(u.protocol)?u.href:''}catch{return ''}};const icon=t=>({hostel:'🏠',tiffin:'🍱',library:'📚',cafe:'☕',bookstore:'📖'})[t]||'📍'; const strip=t=>String(t??'').replace(/\s{2,}/g,' ').trim();function formatText(t){let x=esc(strip(t));x=x.replace(/\*\*(.+?)\*\*/g,'$1');return x.replace(/\n/g,'
')} + function asksForNearbyLocation(text){return /(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(text)&&!/(allen|clc|vibrant|matrix|gurukripa|piprali road|piprali rd|nawalgarh road)/i.test(text)} + function getUserDeviceLocation(){if(!navigator.geolocation)return Promise.resolve(null);return new Promise(resolve=>navigator.geolocation.getCurrentPosition(pos=>resolve({lat:pos.coords.latitude,lng:pos.coords.longitude}),()=>resolve(null),{timeout:5000,maximumAge:60000,enableHighAccuracy:false}))} function speak(text){if(!speaking||!window.speechSynthesis)return;window.speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(String(text).replace(/[*_#]/g,' ').replace(/\n+/g,'. '));u.lang='hi-IN';const vs=window.speechSynthesis.getVoices();const v=vs.find(x=>/^hi(-|_)/i.test(x.lang))||vs.find(x=>/^en(-|_)/i.test(x.lang));if(v)u.voice=v;window.speechSynthesis.speak(u)} function showHostelPills(){const wrap=document.createElement('div');wrap.style.cssText='align-self:stretch;width:100%;';const box=document.createElement('div');box.style.cssText='background:#fff;border:1px solid #e1e8f0;border-radius:16px;padding:12px;box-shadow:0 3px 12px rgba(15,23,42,.06)';const title=document.createElement('div');title.textContent='🏠 Hostel / PG preference';title.style.cssText='font-weight:900;font-size:12px;margin-bottom:8px;color:#172033';box.appendChild(title);const groups=[['Gender',['👧 Girls','👦 Boys','Any gender']],['Monthly budget',['Under ₹5,000','₹5,000–₹8,000','₹8,000+','Any budget']],['Coaching nearby',['Near CLC','Near Matrix','Near Gurukripa','Near Allen']]];groups.forEach(([label,items])=>{const h=document.createElement('div');h.textContent=label;h.style.cssText='font-size:10px;font-weight:800;color:#64748b;margin:9px 0 5px';box.appendChild(h);const row=document.createElement('div');row.style.cssText='display:flex;flex-wrap:wrap;gap:6px';items.forEach(item=>{const b=document.createElement('button');b.type='button';b.textContent=item;b.style.cssText='background:#f8fafc;color:#17365c;border:1px solid #d6e1ee;border-radius:999px;padding:7px 9px;font-size:10px;font-weight:800;cursor:pointer';b.onclick=()=>{const q=label==='Gender'?(item==='Any gender'?'hostel / PG any gender':item+' hostel / PG'):label==='Monthly budget'?(item==='Any budget'?'hostel / PG any budget':'hostel / PG budget '+item):'hostel / PG '+item;sendText(q)};row.appendChild(b)});box.appendChild(row)});wrap.appendChild(box);messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight} function showOwnerLeadForm(){ @@ -45,7 +47,7 @@ const SHH_WIDGET=(()=>{ }; messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight; } - async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return false;if(!meta.action&&/(add\s+(my\s+)?(property|business|listing)|register\s+(my\s+)?(property|business|listing)|submit\s+(my\s+)?(property|business|listing)|owner\s+(listing|property)\s+(request|submit|add)|add\s+(my\s+)?(hostel|pg|tiffin|library|cafe|bookstore)\s+(listing|property))/i.test(text)){showOwnerLeadForm();return}if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),18000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[],ownerLead:meta.ownerLead||null}),signal:ctl.signal})}finally{clearTimeout(timer)}if(!r.ok)throw Error('Chat request failed ('+r.status+')');const d=await r.json();lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support';return true}catch(e){addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':'Ji, live AI service me temporary issue aaya. Please try again.',true);document.getElementById('shh-status').textContent='Ready · Please try again';return false}finally{busy=false}} + async function sendText(value,meta={}){const text=String(value??'').trim()||field.value.trim();if(!text||busy)return false;if(!meta.action&&/(add\s+(my\s+)?(property|business|listing)|register\s+(my\s+)?(property|business|listing)|submit\s+(my\s+)?(property|business|listing)|owner\s+(listing|property)\s+(request|submit|add)|add\s+(my\s+)?(hostel|pg|tiffin|library|cafe|bookstore)\s+(listing|property))/i.test(text)){showOwnerLeadForm();return}if(/\bappointment\b|\bmeeting\b|\bconsultation\b/i.test(text)&&/(book|schedule|chahiye|chahta|chahti|karna|karo|fix|set|request|meet)/i.test(text)){addMessage(text,false);showAppointmentForm();return}field.value='';addMessage(text,false);busy=true;document.getElementById('shh-status').textContent='Thinking · checking live listings…';try{const clientCoords=asksForNearbyLocation(text)?await getUserDeviceLocation():null;const ctl=new AbortController(),timer=setTimeout(()=>ctl.abort(),18000);let r;try{r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json',apikey:KEY,Authorization:'Bearer '+KEY},body:JSON.stringify({message:text,history:history.slice(-8),sessionId,clientCoords,action:meta.action||'',focusPropertyId:meta.focusPropertyId||'',focusPropertyType:meta.focusPropertyType||'',shownPropertyIds:meta.shownPropertyIds||[],ownerLead:meta.ownerLead||null}),signal:ctl.signal})}finally{clearTimeout(timer)}if(!r.ok)throw Error('Chat request failed ('+r.status+')');const d=await r.json();lastData=d;const reply=d?.reply||'Ji, main StudentHubHelp par aapki help karta hoon.';addMessage(reply,true,d);history.push({role:'user',text},{role:'assistant',text:reply});if(history.length>16)history=history.slice(-16);const topic=document.getElementById('shh-topic');if(d?.primaryTopic){topic.textContent='Focus · '+strip(d.primaryTopic);topic.style.display='block'}document.getElementById('shh-status').textContent='Ready · Live listing support';return true}catch(e){addMessage(e?.name==='AbortError'?'Response thoda slow ho gaya. Please try again.':'Ji, live AI service me temporary issue aaya. Please try again.',true);document.getElementById('shh-status').textContent='Ready · Please try again';return false}finally{busy=false}} const tts=header.querySelector('#shh-tts');tts.onclick=()=>{speaking=!speaking;tts.textContent=speaking?'🔊':'🔇';if(!speaking&&window.speechSynthesis)window.speechSynthesis.cancel()};header.querySelector('#shh-max').onclick=()=>{expanded=!expanded;root.classList.toggle('shh-expanded',expanded);header.querySelector('#shh-max').textContent=expanded?'↙':'↗'};header.querySelector('#shh-close-btn').onclick=()=>{win.style.display='none';launcher.style.display='flex'};launcher.onclick=()=>{win.style.display='flex';launcher.style.display='none';messages.scrollTop=messages.scrollHeight};input.querySelector('#shh-send-btn').onclick=()=>sendText(); const field=input.querySelector('#shh-input');field.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();sendText()}});input.querySelector('#shh-clear').onclick=()=>{history=[];lastData=null;messages.innerHTML='';document.getElementById('shh-topic').style.display='none';addMessage(getWelcome(),true)}; function getWelcome(){return'Namaste! 🙏 Main StudentHubHelp ka **Ultra Advance AI Assistant** hoon.\n\nAap jo bhi requirement batayenge—Hostel, Tiffin, Library, Cafe, Bookstore, area, budget ya nearby location—main aapki requirement samajhkar live StudentHubHelp listings mein relevant options dhoondunga.\n\n**Aapko kis cheez ki jankari chahiye?**'} From fc41c5621880b42b08d2256bc3f421ccb012c84b Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:27:42 +0530 Subject: [PATCH 91/95] Validate bundled public widget JavaScript syntax --- .github/workflows/chatbot-pr-check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/chatbot-pr-check.yml b/.github/workflows/chatbot-pr-check.yml index 73af8d9de..5aa489d5f 100644 --- a/.github/workflows/chatbot-pr-check.yml +++ b/.github/workflows/chatbot-pr-check.yml @@ -91,6 +91,7 @@ jobs: run: | node --check tests/chatbot-regression.mjs node --check ../widget.js + node --check public/widget.js if grep -R -nE 'https?://(localhost|127\.0\.0\.1):3000' src; then echo 'Local Express API reference found in client source'; exit 1; fi - name: Type-check React chatbot component run: npx tsc --noEmit --skipLibCheck --jsx react-jsx --target ES2020 --module ESNext --moduleResolution bundler --allowImportingTsExtensions --types vite/client src/components/ChatWidget.tsx From d31a1dbc28c22e11696e5f0e3a5a1293cf791e4f Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:28:14 +0530 Subject: [PATCH 92/95] Gracefully handle geolocation API exceptions --- ai-chatbot/src/components/ChatWidget.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ai-chatbot/src/components/ChatWidget.tsx b/ai-chatbot/src/components/ChatWidget.tsx index 23379d79c..4b8012108 100644 --- a/ai-chatbot/src/components/ChatWidget.tsx +++ b/ai-chatbot/src/components/ChatWidget.tsx @@ -57,11 +57,15 @@ const QUICK_TOPICS = [ async function getUserDeviceLocation(): Promise<{ lat: number; lng: number } | null> { if (typeof navigator === 'undefined' || !navigator.geolocation) return null; return new Promise((resolve) => { - navigator.geolocation.getCurrentPosition( - (position) => resolve({ lat: position.coords.latitude, lng: position.coords.longitude }), - () => resolve(null), - { timeout: 5000, maximumAge: 60000, enableHighAccuracy: false } - ); + try { + navigator.geolocation.getCurrentPosition( + (position) => resolve({ lat: position.coords.latitude, lng: position.coords.longitude }), + () => resolve(null), + { timeout: 5000, maximumAge: 60000, enableHighAccuracy: false } + ); + } catch { + resolve(null); + } }); } From b480adee05ffba954c51ecee6b262f8f8fb9edc3 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:28:18 +0530 Subject: [PATCH 93/95] Gracefully handle geolocation API exceptions --- widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widget.js b/widget.js index e25ca8747..e4ec76383 100644 --- a/widget.js +++ b/widget.js @@ -19,7 +19,7 @@ const SHH_WIDGET=(()=>{ const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const safeUrl=v=>{try{const u=new URL(String(v));return ['http:','https:'].includes(u.protocol)?u.href:''}catch{return ''}};const icon=t=>({hostel:'🏠',tiffin:'🍱',library:'📚',cafe:'☕',bookstore:'📖'})[t]||'📍'; const strip=t=>String(t??'').replace(/\s{2,}/g,' ').trim();function formatText(t){let x=esc(strip(t));x=x.replace(/\*\*(.+?)\*\*/g,'$1');return x.replace(/\n/g,'
')} function asksForNearbyLocation(text){return /(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(text)&&!/(allen|clc|vibrant|matrix|gurukripa|piprali road|piprali rd|nawalgarh road)/i.test(text)} - function getUserDeviceLocation(){if(!navigator.geolocation)return Promise.resolve(null);return new Promise(resolve=>navigator.geolocation.getCurrentPosition(pos=>resolve({lat:pos.coords.latitude,lng:pos.coords.longitude}),()=>resolve(null),{timeout:5000,maximumAge:60000,enableHighAccuracy:false}))} + function getUserDeviceLocation(){if(!navigator.geolocation)return Promise.resolve(null);return new Promise(resolve=>{try{navigator.geolocation.getCurrentPosition(pos=>resolve({lat:pos.coords.latitude,lng:pos.coords.longitude}),()=>resolve(null),{timeout:5000,maximumAge:60000,enableHighAccuracy:false})}catch{resolve(null)}})} function speak(text){if(!speaking||!window.speechSynthesis)return;window.speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(String(text).replace(/[*_#]/g,' ').replace(/\n+/g,'. '));u.lang='hi-IN';const vs=window.speechSynthesis.getVoices();const v=vs.find(x=>/^hi(-|_)/i.test(x.lang))||vs.find(x=>/^en(-|_)/i.test(x.lang));if(v)u.voice=v;window.speechSynthesis.speak(u)} function addMessage(text,isBot,data){const wrap=document.createElement('div');wrap.style.cssText='align-self:'+(isBot?'stretch':'flex-end')+';max-width:'+(isBot?'100%':'88%')+';width:'+(isBot?'100%':'auto')+';';const bubble=document.createElement('div');bubble.style.cssText='background:'+(isBot?'#fff':'#071a33')+';color:'+(isBot?'#172033':'#fff')+';border:'+(isBot?'1px solid #e1e8f0':'0')+';border-radius:18px;padding:11px 13px;font-size:13px;line-height:1.58;box-shadow:0 3px 12px rgba(15,23,42,.06);word-break:break-word;';if(isBot){const meta=document.createElement('div');meta.style.cssText='display:flex;align-items:center;gap:6px;font-size:9px;font-weight:800;color:#64748b;margin-bottom:5px';meta.innerHTML='● StudentHubHelp AI';bubble.appendChild(meta)}const body=document.createElement('div');body.innerHTML=formatText(text);bubble.appendChild(body);wrap.appendChild(bubble);if(isBot&&data)renderData(wrap,data);messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight;if(isBot)speak(strip(text))} function renderPropertyCard(p){const card=document.createElement('div');card.className='shh-card';const loc=[p.area,p.city].filter(Boolean).join(', '),phone=p.phone||p.whatsapp,maps=safeUrl(p.mapsUrl),details=safeUrl(p.detailsUrl);const top=document.createElement('div');top.style.cssText='display:flex;justify-content:space-between;gap:8px;align-items:flex-start';top.innerHTML='
'+icon(p.type)+' '+esc(p.name||'Active listing')+'
'+(loc?'
📍 '+esc(loc)+'
':'')+'
'+(p.verified?'✓ Verified Active':'Active')+'';card.appendChild(top);if(p.distanceLabel){const z=document.createElement("div");z.style.cssText="margin-top:5px;font-size:10px;font-weight:700;color:#475569;line-height:1.4";z.textContent=p.distanceLabel;card.appendChild(z)}if(p.price){const z=document.createElement('div');z.className='shh-card-price';z.textContent=p.price;card.appendChild(z)}if(p.rating!==undefined&&p.rating!==null){const z=document.createElement('div');z.style.cssText='margin-top:5px;font-size:10px;font-weight:900;color:#8a6415';z.textContent='⭐ '+p.rating;card.appendChild(z)}if(p.address){const z=document.createElement('div');z.style.cssText='margin-top:7px;font-size:10px;color:#475569;line-height:1.45';z.innerHTML='Address: '+esc(p.address);card.appendChild(z)}if(p.description){const z=document.createElement('div');z.className='shh-card-desc';z.textContent=strip(p.description);card.appendChild(z)}const vals=Array.isArray(p.highlights)?p.highlights.map(strip).filter(Boolean):[];if(vals.length){const h=document.createElement('div');h.className='shh-tags';vals.slice(0,6).forEach(x=>{const z=document.createElement('span');z.className='shh-tag';z.textContent=x;h.appendChild(z)});card.appendChild(h)}const acts=document.createElement('div');acts.className='shh-actions';if(phone){const a=document.createElement('a');a.className='call';a.href='tel:'+encodeURIComponent(phone);a.textContent='📞 Call Owner';acts.appendChild(a)}if(maps){const a=document.createElement('a');a.className='map';a.href=maps;a.target='_blank';a.rel='noopener noreferrer';a.textContent='🗺️ Google Maps';acts.appendChild(a)}if(details){const a=document.createElement('a');a.className='view';a.href=details;a.textContent='View';acts.appendChild(a)}if(acts.children.length)card.appendChild(acts);return card} From de4b1bcb46b40cbdd51702034b5aa39c42792f40 Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:28:22 +0530 Subject: [PATCH 94/95] Gracefully handle geolocation API exceptions --- ai-chatbot/public/widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-chatbot/public/widget.js b/ai-chatbot/public/widget.js index aea20d96e..f3c03c288 100644 --- a/ai-chatbot/public/widget.js +++ b/ai-chatbot/public/widget.js @@ -19,7 +19,7 @@ const SHH_WIDGET=(()=>{ const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const safeUrl=v=>{try{const u=new URL(String(v));return ['http:','https:'].includes(u.protocol)?u.href:''}catch{return ''}};const icon=t=>({hostel:'🏠',tiffin:'🍱',library:'📚',cafe:'☕',bookstore:'📖'})[t]||'📍'; const strip=t=>String(t??'').replace(/\s{2,}/g,' ').trim();function formatText(t){let x=esc(strip(t));x=x.replace(/\*\*(.+?)\*\*/g,'$1');return x.replace(/\n/g,'
')} function asksForNearbyLocation(text){return /(?:\bnear me\b|\bnearby\b|\bnearest\b|mere paas|mere pass|mere aas paas|aas paas|पास में|मेरे पास)/i.test(text)&&!/(allen|clc|vibrant|matrix|gurukripa|piprali road|piprali rd|nawalgarh road)/i.test(text)} - function getUserDeviceLocation(){if(!navigator.geolocation)return Promise.resolve(null);return new Promise(resolve=>navigator.geolocation.getCurrentPosition(pos=>resolve({lat:pos.coords.latitude,lng:pos.coords.longitude}),()=>resolve(null),{timeout:5000,maximumAge:60000,enableHighAccuracy:false}))} + function getUserDeviceLocation(){if(!navigator.geolocation)return Promise.resolve(null);return new Promise(resolve=>{try{navigator.geolocation.getCurrentPosition(pos=>resolve({lat:pos.coords.latitude,lng:pos.coords.longitude}),()=>resolve(null),{timeout:5000,maximumAge:60000,enableHighAccuracy:false})}catch{resolve(null)}})} function speak(text){if(!speaking||!window.speechSynthesis)return;window.speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(String(text).replace(/[*_#]/g,' ').replace(/\n+/g,'. '));u.lang='hi-IN';const vs=window.speechSynthesis.getVoices();const v=vs.find(x=>/^hi(-|_)/i.test(x.lang))||vs.find(x=>/^en(-|_)/i.test(x.lang));if(v)u.voice=v;window.speechSynthesis.speak(u)} function showHostelPills(){const wrap=document.createElement('div');wrap.style.cssText='align-self:stretch;width:100%;';const box=document.createElement('div');box.style.cssText='background:#fff;border:1px solid #e1e8f0;border-radius:16px;padding:12px;box-shadow:0 3px 12px rgba(15,23,42,.06)';const title=document.createElement('div');title.textContent='🏠 Hostel / PG preference';title.style.cssText='font-weight:900;font-size:12px;margin-bottom:8px;color:#172033';box.appendChild(title);const groups=[['Gender',['👧 Girls','👦 Boys','Any gender']],['Monthly budget',['Under ₹5,000','₹5,000–₹8,000','₹8,000+','Any budget']],['Coaching nearby',['Near CLC','Near Matrix','Near Gurukripa','Near Allen']]];groups.forEach(([label,items])=>{const h=document.createElement('div');h.textContent=label;h.style.cssText='font-size:10px;font-weight:800;color:#64748b;margin:9px 0 5px';box.appendChild(h);const row=document.createElement('div');row.style.cssText='display:flex;flex-wrap:wrap;gap:6px';items.forEach(item=>{const b=document.createElement('button');b.type='button';b.textContent=item;b.style.cssText='background:#f8fafc;color:#17365c;border:1px solid #d6e1ee;border-radius:999px;padding:7px 9px;font-size:10px;font-weight:800;cursor:pointer';b.onclick=()=>{const q=label==='Gender'?(item==='Any gender'?'hostel / PG any gender':item+' hostel / PG'):label==='Monthly budget'?(item==='Any budget'?'hostel / PG any budget':'hostel / PG budget '+item):'hostel / PG '+item;sendText(q)};row.appendChild(b)});box.appendChild(row)});wrap.appendChild(box);messages.appendChild(wrap);messages.scrollTop=messages.scrollHeight} function showOwnerLeadForm(){ From 1a511c00d518726df084d0d226ed2d54b02ce66e Mon Sep 17 00:00:00 2001 From: StudentHub Date: Sun, 8 Nov 2026 22:28:36 +0530 Subject: [PATCH 95/95] Verify combo budget breakdown sums to its total --- ai-chatbot/tests/chatbot-regression.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ai-chatbot/tests/chatbot-regression.mjs b/ai-chatbot/tests/chatbot-regression.mjs index f64fd72d0..798dae1a1 100644 --- a/ai-chatbot/tests/chatbot-regression.mjs +++ b/ai-chatbot/tests/chatbot-regression.mjs @@ -58,6 +58,8 @@ async function runCase(testCase, index) { if (!types.some(type => type.includes('hostel')) || !types.some(type => type.includes('tiffin'))) failures.push('combo must contain both a hostel and a tiffin listing'); if (Number(plan?.totalEstimated) > testCase.maxBudget) failures.push('combined monthly total exceeds budget'); if (Number(plan?.totalEstimated) <= 0) failures.push('combined monthly total is missing/invalid'); + const itemTotal = Array.isArray(plan?.items) ? plan.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) : 0; + if (Math.abs(itemTotal - Number(plan?.totalEstimated)) > 0.01) failures.push('budget breakdown items do not add up to totalEstimated'); if (listings.some(item => String(item.city || '').toLowerCase() !== testCase.city.toLowerCase())) failures.push('combo contains a listing from another city'); if (failures.length) throw new Error(failures.join('; ')); return testCase.query + ' (combo total ₹' + Number(plan.totalEstimated).toLocaleString('en-IN') + ')';