mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-11-01 08:29:51 +00:00
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:
1 parent
26f228c59a
commit
562fbfd961
2 files changed
+191
-14
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user