Make Sasquatch resilient: local glossary answers + backup AI provider

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-29 23:04:41 +07:00
parent 309d012db1
commit cc84713fe4
3 changed files with 122 additions and 24 deletions

View file

@ -16,6 +16,7 @@ import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCVMClient } from '@/hooks/useCVMClient'; import { useCVMClient } from '@/hooks/useCVMClient';
import { callSasquatchAI } from '@/lib/sasquatchBrain'; import { callSasquatchAI } from '@/lib/sasquatchBrain';
import { answerFromGlossary } from '@/lib/glossary';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { fetchWeather, calculateSkateability } from '@/lib/weather'; import { fetchWeather, calculateSkateability } from '@/lib/weather';
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
@ -440,6 +441,21 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
return; 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 // 2. Build conversation history for AI
const history = chatMessages.map((m) => ({ const history = chatMessages.map((m) => ({
role: m.role === 'user' ? ('user' as const) : ('assistant' as const), role: m.role === 'user' ? ('user' as const) : ('assistant' as const),
@ -554,8 +570,12 @@ Instructions:
} }
if (!aiSucceeded) { if (!aiSucceeded) {
const fallback = staticAnswer ?? // Last-resort local answers so the user still gets something useful
"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. 🛹"; // 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() }); appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() });
} }
} catch (err) { } catch (err) {

View file

@ -370,3 +370,47 @@ export function searchTricks(query: string): FlatTrick[] {
return false; 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}`;
}

View file

@ -24,19 +24,15 @@ export type ChatTurn = { role: 'user' | 'assistant'; content: string };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** // ─── Backup provider — Pollinations (keyless, OpenAI-compatible) ─────────────
* Call the AI with model fallback + light retry on rate limits. // 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.
* Strategy: for each model, try once; on a 429 (rate limit) wait a short, const POLLINATIONS_URL = 'https://text.pollinations.ai/openai';
* increasing beat and retry that same model once before moving on. This const POLLINATIONS_MODELS = ['openai', 'mistral'];
* 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<string> {
let lastError = '';
/** Try OpenRouter's free models, respecting 429 backoff. Returns text or ''. */
async function tryOpenRouter(messages: ChatTurn[], maxTokens: number): Promise<string> {
for (const model of FREE_MODELS) { 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++) { for (let attempt = 0; attempt < 2; attempt++) {
try { try {
const response = await fetch(OPENROUTER_URL, { 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) { 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 retryAfter = Number(response.headers.get('retry-after'));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? Math.min(retryAfter * 1000, 2500) ? Math.min(retryAfter * 1000, 2500)
: 600; : 600;
console.warn(`[Sasquatch] ${model} rate-limited, retrying in ${waitMs}ms`); console.warn(`[Sasquatch] ${model} rate-limited, retrying in ${waitMs}ms`);
await sleep(waitMs); await sleep(waitMs);
continue; // retry same model continue;
} }
if (!response.ok) { if (!response.ok) {
const err = await response.text(); console.warn(`[Sasquatch] OpenRouter ${model} failed ${response.status}`);
console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err); break;
lastError = `${response.status}: ${err}`;
break; // move to next model
} }
const data = await response.json(); const data = await response.json();
const content = data.choices?.[0]?.message?.content ?? ''; const content = data.choices?.[0]?.message?.content ?? '';
if (content) return content; if (content) return content;
break; // empty content — try next model break;
} catch (err) { } catch (err) {
console.warn(`[Sasquatch] model ${model} threw:`, err); console.warn(`[Sasquatch] OpenRouter ${model} threw:`, err);
lastError = String(err); break;
break; // network error — try next model
} }
} }
} }
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<string> {
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<string> {
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. */ /** Parse a `{"tool":"name","args":{…}}` tool call out of an AI response, if any. */