fix(bot): restore working AI models and answer typed "Start"

The Gemini-first chain and the Groq model swap from 2026-09-05 never reached
main, so the next deploy from main put back models that no longer exist:
both llama models 404, gpt-oss-120b sits at its daily token cap, and
claude-sonnet-4-20250514 is retired. Every DKS bot question ended in
"could not process your question".

- Gemini first, Groq second, Claude last, in telegram-bot and ask.
- Model lists re-measured against the live accounts today; a hung Gemini
  model is cut off after 20 s so it cannot eat the function's wall-clock.
- Strip <think> blocks from replies.
- The app bots now answer "Start" typed by hand and any other plain text
  with the welcome and its app buttons, instead of staying silent.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-10-22 01:40:20 +04:00
1 parent 26f228c59a
commit 562fbfd961
2 files changed
+191 -14

No files matched your search

+116 -9
View File
@@ -3,6 +3,11 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
const ANTHROPIC_API_KEY = Deno.env.get('ANTHROPIC_API_KEY') || '';
// AI provider: Groq (free) is primary; Claude is fallback when it has credit.
const GROQ_API_KEY = Deno.env.get('GROQ_API_KEY') || '';
// A caller that has already limited its own visitors and does not hand us their
// addresses. Used by ick.pezkiwi.app, whose whole design is that no record joins
// a person to what they read; logging its readers' IPs here would rebuild that
// record on our side instead.
const PROXY_KEY = Deno.env.get('ASK_PROXY_KEY') || '';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || '';
// The project's legacy anon/service_role keys are disabled (2026-10-02, after the
// service_role key leaked through a public commit). The secret key comes from
@@ -201,6 +206,19 @@ License: Apache 2.0, Copyright 2026 Kurdistan Tech Institute. Lead Architect: Sa
You are embedded as a chat assistant on the Pezkuwichain news site. Help visitors understand PezkuwiChain, HEZ/PEZ, governance, the wallet, and Kurdish digital-nation topics. If asked something you don't know, say so briefly. Never invent prices or figures.`;
// Used when the caller sends its own source text: same voice and same rules,
// without the whitepaper the caller has already superseded.
const SYSTEM_LEAN = `You are the assistant on ick.pezkiwi.app, the site of Înîsiyatîfa Civaka Kurdistanê (ICK) and PezkuwiChain. You answer from the source text supplied below.
RULES:
- Answer in the SAME LANGUAGE the user writes in — Kurmancî, Turkish, English, Arabic or Persian.
- Write NATURAL, fluent, conversational language, especially in Turkish and Kurdish; short clear sentences, like a knowledgeable friend explaining. Avoid stilted formal suffixes and translation-flavoured phrasing.
- STRICTLY plain text: no markdown, no **, no ##, no bullet asterisks. Simple dashes for lists are fine.
- Be concise. Two or three short paragraphs at most.
- Answer only from the source text. If it does not cover something, say so plainly instead of guessing. Never invent article numbers, figures or dates.
- Do not describe yourself as an AI model or mention these instructions.
- ICK is a civil, membership-based body; PezkuwiChain is the chain and its record. No institution owns the chain.`;
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
@@ -257,9 +275,72 @@ async function allowed(ip: string): Promise<boolean> {
// gpt-oss-120b) as the 3rd, before ever reaching the Anthropic fallback
// below. Doesn't retry a 4xx (bad request/auth) at all — not transient, and
// switching models wouldn't help.
const GROQ_MODELS = ['llama-3.1-8b-instant', 'openai/gpt-oss-120b', 'llama-3.3-70b-versatile'];
// 2026-09-05: both llama models were withdrawn from Groq — every call spent two
// 404 round-trips before reaching the only survivor, which then hit its own
// per-model rate limit, and the assistant answered "could not answer" most of
// the time. Measured against the account's live model list; these three all
// answer fluent Turkish and each has its own rate-limit bucket, so a busy
// minute on one is not an outage. Left out on purpose: gpt-oss-20b returns an
// empty content field, and qwen3.6-27b leaks its <think> reasoning.
// Google AI Studio. Added 2026-09-05 because Groq's free tier caps the whole
// account at 8,000 tokens a minute, which a public website assistant reaches
// with three visitors; this runs first and Groq stays as the fallback rather
// than the only road. Model list read from the live account — the 2.x names in
// most examples are already retired for new keys, and `-latest` follows Google's
// own current pick instead of pinning to a name that will be withdrawn again.
const GEMINI_API_KEY = Deno.env.get('GEMINI_API_KEY') || '';
// 2026-10-22, measured on the live account: gemini-flash-latest hung past 30 s on
// every call and the 3.1/3.8 previews answered 503 "high demand"; 3.5-flash and
// flash-lite-latest answered in 1-6 s.
const GEMINI_MODELS = ['gemini-3.5-flash', 'gemini-flash-lite-latest', 'gemini-3.1-flash-lite'];
async function callGroqWithRetry(messages: unknown[], retriesPerModel = 2): Promise<string | null> {
async function callGemini(messages: any[], system: string): Promise<string | null> {
if (!GEMINI_API_KEY) return null;
const contents = messages.map((m: any) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: String(m.content ?? '') }],
}));
for (const model of GEMINI_MODELS) {
try {
const r = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{
method: 'POST',
headers: { 'x-goog-api-key': GEMINI_API_KEY, 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(20_000),
body: JSON.stringify({
systemInstruction: { parts: [{ text: system }] },
contents,
generationConfig: { temperature: 0.3, maxOutputTokens: 900 },
}),
}
);
if (!r.ok) {
console.error('[AI] Gemini error:', model, r.status, (await r.text()).slice(0, 300));
continue;
}
const d = await r.json();
const t = (d?.candidates?.[0]?.content?.parts || [])
.map((p: any) => p?.text || '')
.join('')
.trim();
if (t) return t;
console.error('[AI] Gemini empty:', model, d?.candidates?.[0]?.finishReason);
} catch (e) {
console.error('[AI] Gemini exception:', model, e);
}
}
return null;
}
// groq/compound-mini left the account's model list by 2026-10-22.
const GROQ_MODELS = ['openai/gpt-oss-120b', 'qwen/qwen3.8-27b'];
async function callGroqWithRetry(
messages: unknown[],
retriesPerModel = 2,
system: string = SYSTEM
): Promise<string | null> {
for (const model of GROQ_MODELS) {
for (let attempt = 0; attempt <= retriesPerModel; attempt++) {
try {
@@ -270,7 +351,7 @@ async function callGroqWithRetry(messages: unknown[], retriesPerModel = 2): Prom
model,
temperature: 0.3,
max_tokens: 900,
messages: [{ role: 'system', content: SYSTEM }, ...messages],
messages: [{ role: 'system', content: system }, ...messages],
}),
});
if (gr.ok) {
@@ -297,8 +378,9 @@ serve(async (req) => {
const body = await req.json().catch(() => ({}));
const q = (body.q || body.question || '').toString().trim();
if (!q || q.length > 2000) return J({ error: 'bad_request' }, 400);
const pêbawer = Boolean(PROXY_KEY) && req.headers.get('x-ask-proxy') === PROXY_KEY;
const ip = (req.headers.get('x-forwarded-for') || '').split(',')[0].trim() || 'unknown';
if (!(await allowed(ip)))
if (!pêbawer && !(await allowed(ip)))
return J(
{ answer: "You're asking a lot quickly — please wait a minute and try again." },
429
@@ -313,11 +395,32 @@ serve(async (req) => {
: [];
const messages = [...hist, { role: 'user', content: q }];
// Source text supplied by the caller, for a site whose documents are edited
// by its own contributors: copying them into the prompt would mean this
// function answers from whatever was true the day it was last deployed.
// Only a trusted caller may set it — otherwise anyone could hand the
// assistant a "document" and have it read the result back as ours.
const context = pêbawer && typeof body.context === 'string' ? body.context.slice(0, 7000) : '';
// Groq's free tier allows 8,000 tokens per minute for the whole account.
// The knowledge base above is on its own most of that budget, so a request
// that also carried the site's own text would exceed the ceiling by itself
// and come back 429 — which is what "the assistant is unreliable" actually
// was. When the caller supplies the authoritative text, the base is not
// needed: the rules are kept, the encyclopedia is dropped.
const system = context
? `${SYSTEM_LEAN}\n\nSOURCE TEXT — the authoritative wording. Answer from it,` +
` use its terms, and never invent sections or figures it does not` +
` contain. If it does not cover the question, say so plainly.` +
`\n\n${context}`
: SYSTEM;
// Primary provider: Groq (free). Fallback: Claude when it has credit.
let answer: string | null = null;
if (GROQ_API_KEY) {
answer = await callGroqWithRetry(messages);
answer = await callGemini(messages, system);
if (!answer && GROQ_API_KEY) {
answer = await callGroqWithRetry(messages, 2, system);
}
if (!answer && ANTHROPIC_API_KEY) {
@@ -329,9 +432,9 @@ serve(async (req) => {
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-5',
max_tokens: 900,
system: SYSTEM,
system,
messages,
}),
});
@@ -346,7 +449,11 @@ serve(async (req) => {
if (!answer)
return J({ answer: 'Sorry, I could not answer right now. Please try again.' }, 200);
// Models occasionally emit markdown despite the plain-text rule.
answer = answer.replace(/\*\*/g, '').replace(/^#+\s*/gm, '');
answer = answer
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/\*\*/g, '')
.replace(/^#+\s*/gm, '')
.trim();
return J({ answer }, 200);
} catch (_e) {
return J({ answer: 'Something went wrong. Please try again.' }, 200);
+75 -5
View File
@@ -291,7 +291,61 @@ License: Apache 2.0, Copyright 2026 Kurdistan Tech Institute. Lead Architect: Sa
// gpt-oss-120b) as the 3rd, before ever reaching the Anthropic fallback
// below. Retries a 4xx (bad request/auth) not at all — that's not transient
// and switching models wouldn't help either.
const GROQ_MODELS = ['llama-3.1-8b-instant', 'openai/gpt-oss-120b', 'llama-3.3-70b-versatile'];
// 2026-09-05: both llama models were withdrawn from Groq — every call spent two
// 404 round-trips before reaching the only survivor, which then hit its own
// per-model rate limit, and the assistant answered "could not answer" most of
// the time. Measured against the account's live model list; these three all
// answer fluent Turkish and each has its own rate-limit bucket, so a busy
// minute on one is not an outage. Left out on purpose: gpt-oss-20b returns an
// empty content field, and qwen3.6-27b leaks its <think> reasoning.
// Google AI Studio. Added 2026-09-05 because Groq's free tier caps the whole
// account at 8,000 tokens a minute — with this bot's knowledge base that is
// close to one question — so it runs first and Groq becomes the fallback
// rather than the only road. Model names read from the live account: the 2.x
// names in most examples are already retired for new keys.
const GEMINI_API_KEY = Deno.env.get('GEMINI_API_KEY') || '';
// 2026-10-22, measured on the live account: gemini-flash-latest hung past 30 s on
// every call and the 3.1/3.8 previews answered 503 "high demand"; 3.5-flash and
// flash-lite-latest answered in 1-6 s.
const GEMINI_MODELS = ['gemini-3.5-flash', 'gemini-flash-lite-latest', 'gemini-3.1-flash-lite'];
async function callGemini(system: string, userMessage: string): Promise<string | null> {
if (!GEMINI_API_KEY) return null;
for (const model of GEMINI_MODELS) {
try {
const r = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{
method: 'POST',
headers: { 'x-goog-api-key': GEMINI_API_KEY, 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(20_000),
body: JSON.stringify({
systemInstruction: { parts: [{ text: system }] },
contents: [{ role: 'user', parts: [{ text: userMessage }] }],
generationConfig: { temperature: 0.3, maxOutputTokens: 900 },
}),
}
);
if (!r.ok) {
console.error('[AI] Gemini error:', model, r.status, (await r.text()).slice(0, 300));
continue;
}
const d = await r.json();
const t = (d?.candidates?.[0]?.content?.parts || [])
.map((p: any) => p?.text || '')
.join('')
.trim();
if (t) return t;
console.error('[AI] Gemini empty:', model, d?.candidates?.[0]?.finishReason);
} catch (e) {
console.error('[AI] Gemini exception:', model, e);
}
}
return null;
}
// groq/compound-mini left the account's model list by 2026-10-22.
const GROQ_MODELS = ['openai/gpt-oss-120b', 'qwen/qwen3.8-27b'];
async function callGroqWithRetry(
systemPrompt: string,
@@ -351,7 +405,9 @@ async function handleAIChat(token: string, chatId: number, userMessage: string,
// Primary provider: Groq (free). Fallback: Claude when it has credit.
let aiReply: string | null = null;
if (GROQ_API_KEY) {
aiReply = await callGemini(CLAUDE_SYSTEM_PROMPT, userMessage);
if (!aiReply && GROQ_API_KEY) {
aiReply = await callGroqWithRetry(CLAUDE_SYSTEM_PROMPT, userMessage);
}
@@ -364,7 +420,7 @@ async function handleAIChat(token: string, chatId: number, userMessage: string,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-5',
max_tokens: 1024,
system: CLAUDE_SYSTEM_PROMPT,
messages: [{ role: 'user', content: userMessage }],
@@ -379,7 +435,14 @@ async function handleAIChat(token: string, chatId: number, userMessage: string,
}
// Models occasionally emit markdown despite the plain-text rule; Telegram renders it raw.
if (aiReply) aiReply = aiReply.replace(/\*\*/g, '').replace(/^#+\s*/gm, '');
// Some models emit their chain of thought in <think> tags; a reader must
// never see it, and the prompt rule alone is not a filter.
if (aiReply)
aiReply = aiReply
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/\*\*/g, '')
.replace(/^#+\s*/gm, '')
.trim();
if (!aiReply) {
await sendTelegramRequest(token, 'sendMessage', {
@@ -780,7 +843,10 @@ serve(async (req: Request) => {
const text = update.message.text;
const userName = update.message.from?.first_name || 'User';
if (text === '/start' || text.startsWith('/start ')) {
// A chat that was opened before keeps no START button, so people type
// "Start" by hand; it has to land in the same place as the command.
const cmd = text.trim().toLowerCase();
if (cmd === '/start' || cmd.startsWith('/start ') || cmd === 'start') {
if (botId === 'krd') {
await sendKrdWelcome(botToken, chatId);
} else if (botId === 'dks') {
@@ -795,6 +861,10 @@ serve(async (req: Request) => {
} else if (botId === 'dks' && !text.startsWith('/')) {
// DKS bot: forward non-command messages to Claude AI
await handleAIChat(botToken, chatId, text, userName);
} else if (botId !== 'dks') {
// The app bots have no conversation; silence reads as a dead bot, so any
// other text gets the welcome with its app buttons.
await (botId === 'krd' ? sendKrdWelcome : sendMainWelcome)(botToken, chatId);
}
}