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:
parent
309d012db1
commit
cc84713fe4
3 changed files with 122 additions and 24 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue