From cc84713fe43e1163ba9489c4f09fcb29b3b1c132 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 29 Jul 2026 23:04:41 +0700 Subject: [PATCH] Make Sasquatch resilient: local glossary answers + backup AI provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the "brain's catching traffic" dead-end for trick/terminology questions and reduces reliance on a single flaky free API. - Local glossary answerer (answerFromGlossary): trick/how-to/terminology questions ("what is a feeble grind", "how do I do a kickflip", "grosso grind") are now answered instantly from the 150+ entry glossary with ZERO API calls β€” so they never hit a rate limit. - Wired into SasquatchGuide as (a) a fast path before the AI and (b) a last-resort fallback when the AI is unreachable. - sasquatchBrain now has multi-provider fallback: OpenRouter free models (with 429 backoff) β†’ keyless Pollinations backup. Only throws if both fail, letting the caller fall back to local knowledge. - Replaced the unhelpful fallback message with useful local links + a nudge to ask about a specific trick. Co-authored-by: shakespeare.diy --- src/components/SasquatchGuide.tsx | 24 +++++++++- src/lib/glossary.ts | 44 +++++++++++++++++ src/lib/sasquatchBrain.ts | 78 ++++++++++++++++++++++--------- 3 files changed, 122 insertions(+), 24 deletions(-) diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 6f6123d..cc57213 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -16,6 +16,7 @@ import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCVMClient } from '@/hooks/useCVMClient'; import { callSasquatchAI } from '@/lib/sasquatchBrain'; +import { answerFromGlossary } from '@/lib/glossary'; import { cn } from '@/lib/utils'; import { fetchWeather, calculateSkateability } from '@/lib/weather'; import ngeohash from 'ngeohash'; @@ -440,6 +441,21 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { return; } + // 1b. Trick / terminology questions β†’ answer locally from the glossary. + // No API call, so this NEVER hits a rate limit. Skip if the question + // clearly wants live data (e.g. "find a kickflip coach"). + if (!needsLiveData(question)) { + const glossaryAnswer = answerFromGlossary(question); + if (glossaryAnswer) { + appendMessage({ + role: 'sasquatch', + text: glossaryAnswer, + timestamp: Date.now(), + }); + return; + } + } + // 2. Build conversation history for AI const history = chatMessages.map((m) => ({ role: m.role === 'user' ? ('user' as const) : ('assistant' as const), @@ -554,8 +570,12 @@ Instructions: } if (!aiSucceeded) { - const fallback = staticAnswer ?? - "My brain's catching a lot of traffic right now 🦧 β€” give it a few seconds and ask again. Meanwhile you can hit the πŸ—ΊοΈ Map for spots, πŸ§ͺ The Lab for coaches, or the πŸ“– Glossary to level up your trick vocab. πŸ›Ή"; + // Last-resort local answers so the user still gets something useful + // even when the AI is unreachable: static FAQ, then glossary. + const fallback = + staticAnswer ?? + answerFromGlossary(question) ?? + "I couldn't reach my AI brain just now 🦧 β€” but I've still got you. Try:\n\nβ€’ πŸ—ΊοΈ [Map](/) β€” spots, shops, events near you\nβ€’ πŸ§ͺ [The Lab](/lab) β€” coaches, filmers, editors\nβ€’ πŸ“– [Glossary](/glossary) β€” 150+ tricks & terms\nβ€’ πŸ‘₯ [Crews](/crews) β€” find your people\n\n…or ask me about a specific trick (e.g. \"what is a nollie backside flip\") and I'll answer straight from the glossary. πŸ›Ή"; appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() }); } } catch (err) { diff --git a/src/lib/glossary.ts b/src/lib/glossary.ts index a7f1ebf..6ff2df5 100644 --- a/src/lib/glossary.ts +++ b/src/lib/glossary.ts @@ -370,3 +370,47 @@ export function searchTricks(query: string): FlatTrick[] { return false; }); } + +// ─── Local knowledge answerer (no API needed) ──────────────────────────────── + +/** Normalize a name/aka for matching: lowercase, strip punctuation & spaces. */ +function norm(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +/** Pre-computed lookup of every trick by its normalized name + aka names. */ +const TRICK_INDEX: Array<{ key: string; trick: FlatTrick }> = ALL_TRICKS.flatMap((t) => { + const keys = [t.name, ...(t.aka ?? [])].map(norm).filter(Boolean); + return keys.map((key) => ({ key, trick: t })); +}).sort((a, b) => b.key.length - a.key.length); // longest (most specific) first + +/** + * Try to answer a trick / terminology question directly from the glossary, + * with NO network call. Returns a formatted answer string, or null if the + * question doesn't clearly reference a known trick. + * + * Handles phrasings like: + * "what is a feeble grind" Β· "how do i do a kickflip" + * "grosso grind" Β· "explain the tre flip" + */ +export function answerFromGlossary(question: string): string | null { + const nq = norm(question); + if (!nq) return null; + + // Find the most specific trick whose name/aka appears in the question. + const hit = TRICK_INDEX.find(({ key }) => key.length >= 3 && nq.includes(key)); + if (!hit) return null; + + const t = hit.trick; + const akaLine = t.aka && t.aka.length ? `\n_Also known as: ${t.aka.join(', ')}._` : ''; + const isHowTo = /\bhow\b|do a|do the|land|learn/i.test(question); + const lead = isHowTo + ? `Here's the breakdown on the **${t.name}** πŸ›Ή` + : `**${t.name}** β€” ${t.categoryGlyph} ${t.categoryTitle}`; + + const howToTail = isHowTo + ? `\n\nWant to dial it in? Check **The Lab** for a coach near you, or hit the πŸ“– [Glossary](/glossary) for related tricks.` + : `\n\nMore in the πŸ“– [Glossary](/glossary).`; + + return `${lead}\n\n${t.desc}${akaLine}${howToTail}`; +} diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts index 5275c63..f1a33cc 100644 --- a/src/lib/sasquatchBrain.ts +++ b/src/lib/sasquatchBrain.ts @@ -24,19 +24,15 @@ export type ChatTurn = { role: 'user' | 'assistant'; content: string }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -/** - * Call the AI with model fallback + light retry on rate limits. - * - * Strategy: for each model, try once; on a 429 (rate limit) wait a short, - * increasing beat and retry that same model once before moving on. This - * smooths over the free tier's bursty limits without hammering the API. - * Throws only if every model fails after its retries. - */ -export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise { - let lastError = ''; +// ─── Backup provider β€” Pollinations (keyless, OpenAI-compatible) ───────────── +// Independent of OpenRouter, so if OpenRouter's shared free key is fully +// rate-limited we still have a second brain to fall back to. No API key needed. +const POLLINATIONS_URL = 'https://text.pollinations.ai/openai'; +const POLLINATIONS_MODELS = ['openai', 'mistral']; +/** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */ +async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise { for (const model of FREE_MODELS) { - // Up to 2 attempts per model (initial + 1 retry after backoff on 429). for (let attempt = 0; attempt < 2; attempt++) { try { const response = await fetch(OPENROUTER_URL, { @@ -51,36 +47,74 @@ export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Pr }); if (response.status === 429 && attempt === 0) { - // Rate limited β€” respect Retry-After if present, else short backoff. const retryAfter = Number(response.headers.get('retry-after')); const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 2500) : 600; console.warn(`[Sasquatch] ${model} rate-limited, retrying in ${waitMs}ms`); await sleep(waitMs); - continue; // retry same model + continue; } if (!response.ok) { - const err = await response.text(); - console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err); - lastError = `${response.status}: ${err}`; - break; // move to next model + console.warn(`[Sasquatch] OpenRouter ${model} failed ${response.status}`); + break; } const data = await response.json(); const content = data.choices?.[0]?.message?.content ?? ''; if (content) return content; - break; // empty content β€” try next model + break; } catch (err) { - console.warn(`[Sasquatch] model ${model} threw:`, err); - lastError = String(err); - break; // network error β€” try next model + console.warn(`[Sasquatch] OpenRouter ${model} threw:`, err); + break; } } } + return ''; +} - throw new Error(`All AI models failed. Last error: ${lastError}`); +/** Try the keyless Pollinations backup. Returns text or ''. */ +async function tryPollinations(messages: ChatTurn[], maxTokens: number): Promise { + for (const model of POLLINATIONS_MODELS) { + try { + const response = await fetch(POLLINATIONS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages, max_tokens: maxTokens }), + }); + if (!response.ok) { + console.warn(`[Sasquatch] Pollinations ${model} failed ${response.status}`); + continue; + } + const data = await response.json(); + const content = + data.choices?.[0]?.message?.content ?? + (typeof data === 'string' ? data : ''); + if (content) return content; + } catch (err) { + console.warn(`[Sasquatch] Pollinations ${model} threw:`, err); + } + } + return ''; +} + +/** + * Call the AI with multi-provider fallback + rate-limit backoff. + * + * Order: OpenRouter free models (with 429 retry) β†’ keyless Pollinations backup. + * Throws only if BOTH providers fail, so the caller can fall back to local + * knowledge (static FAQ / glossary). + */ +export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise { + const primary = await tryOpenRouter(messages, maxTokens); + if (primary) return primary; + + console.warn('[Sasquatch] OpenRouter exhausted β€” trying backup provider'); + const backup = await tryPollinations(messages, maxTokens); + if (backup) return backup; + + throw new Error('All AI providers failed (OpenRouter + backup).'); } /** Parse a `{"tool":"name","args":{…}}` tool call out of an AI response, if any. */