feat(backend): switch AI provider from Anthropic to GigaChat (Sber) — OAuth token caching, callAI dispatch

This commit is contained in:
wasrusgen 2026-05-10 14:22:53 +03:00
parent 5ecea8fd82
commit 01aa47773e

View File

@ -11,8 +11,9 @@
*
* Script Properties (Project Settings → Script Properties):
* BOT_TOKEN — токен Telegram-бота (для проверки initData и отправки уведомлений)
* ANTHROPIC_API_KEY — ключ Claude API
* ANTHROPIC_MODEL — опционально, модель (default: claude-haiku-4-5-20251001)
* GIGACHAT_AUTH_KEY — Authorization key из кабинета developers.sber.ru (Base64 client_id:client_secret)
* GIGACHAT_MODEL — опционально, модель (default: GigaChat-Pro)
* GIGACHAT_SCOPE — опционально, scope (default: GIGACHAT_API_PERS)
* ADMIN_TG_ID — tg_id куратора, кому слать алерты об ошибках
*/
@ -259,7 +260,7 @@ function handlePodbor(body) {
// Build prompt and call Claude
const measurement = measurementId ? getMeasurement(measurementId) : null;
const prompt = buildPickerPrompt(checklist, measurement, clientName);
const ai = callClaude(prompt);
const ai = callAI(prompt);
// Update lead row with AI response
updateLeadAI(id, ai);
@ -459,7 +460,7 @@ function updateLeadAI(leadId, ai) {
for (let i = 1; i < data.length; i++) {
if (data[i][0] === leadId) {
const props = PropertiesService.getScriptProperties();
const model = props.getProperty("ANTHROPIC_MODEL") || "claude-haiku-4-5-20251001";
const model = props.getProperty("GIGACHAT_MODEL") || "GigaChat-Pro";
s.getRange(i + 1, 8).setValue(JSON.stringify(ai.json || ai.text || ""));
s.getRange(i + 1, 9).setValue(model);
s.getRange(i + 1, 10).setValue(ai.tokens || 0);
@ -544,26 +545,82 @@ function buildPickerPrompt(checklist, measurement, clientName) {
return "Подбери технику для следующего клиента:\n\n" + JSON.stringify(payload, null, 2);
}
function callClaude(userPrompt) {
// =================================================================
// GigaChat (Sber) — primary AI provider
// =================================================================
/**
* Получает access_token GigaChat. Кеширует в CacheService на 25 минут
* (токен живёт 30 минут — оставляем 5 минут запаса).
*/
function getGigaChatToken() {
const cache = CacheService.getScriptCache();
const cached = cache.get("gigachat_token");
if (cached) return cached;
const props = PropertiesService.getScriptProperties();
const apiKey = props.getProperty("ANTHROPIC_API_KEY");
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not in Script Properties");
// Стабильное имя без даты — Anthropic сам резолвит до свежего снапшота
const model = props.getProperty("ANTHROPIC_MODEL") || "claude-haiku-4-5";
const authKey = props.getProperty("GIGACHAT_AUTH_KEY");
if (!authKey) throw new Error("GIGACHAT_AUTH_KEY not in Script Properties");
const scope = props.getProperty("GIGACHAT_SCOPE") || "GIGACHAT_API_PERS";
const res = UrlFetchApp.fetch("https://ngw.devices.sberbank.ru:9443/api/v2/oauth", {
method: "post",
headers: {
"Authorization": "Basic " + authKey,
"RqUID": Utilities.getUuid(),
"Accept": "application/json",
},
contentType: "application/x-www-form-urlencoded",
payload: "scope=" + encodeURIComponent(scope),
muteHttpExceptions: true,
});
const status = res.getResponseCode();
const text = res.getContentText();
if (status >= 400) {
log("gigachat_auth_error", null, { status, text: text.slice(0, 400) });
throw new Error("GigaChat auth HTTP " + status + ": " + text.slice(0, 300));
}
const data = JSON.parse(text);
const token = data.access_token || data.tok;
if (!token) throw new Error("No access_token in GigaChat response: " + text.slice(0, 300));
cache.put("gigachat_token", token, 1500); // 25 минут
return token;
}
/**
* Вызов AI (GigaChat Chat Completions). Возвращает { json, text, tokens, error? }.
*/
function callAI(userPrompt) {
const props = PropertiesService.getScriptProperties();
const model = props.getProperty("GIGACHAT_MODEL") || "GigaChat-Pro";
const temperature = parseFloat(getSetting("AI_TEMPERATURE") || "0.3");
let token;
try {
token = getGigaChatToken();
} catch (e) {
return { json: null, text: "AI auth: " + e.message, tokens: 0, error: true };
}
const payload = {
model,
max_tokens: 4000,
temperature,
system: SYSTEM_PROMPT_PICKER,
messages: [{ role: "user", content: userPrompt }],
max_tokens: 4000,
messages: [
{ role: "system", content: SYSTEM_PROMPT_PICKER },
{ role: "user", content: userPrompt },
],
};
const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
const res = UrlFetchApp.fetch("https://gigachat.devices.sberbank.ru/api/v1/chat/completions", {
method: "post",
headers: {
"Authorization": "Bearer " + token,
"Accept": "application/json",
},
contentType: "application/json",
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
@ -571,20 +628,27 @@ function callClaude(userPrompt) {
const status = res.getResponseCode();
const text = res.getContentText();
if (status >= 400) {
log("claude_error", null, { status, model, text: text.slice(0, 500) });
// Surface the actual Anthropic error so we can debug from /api/test_claude
log("gigachat_error", null, { status, model, text: text.slice(0, 500) });
let errMsg = text.slice(0, 300);
try {
const j = JSON.parse(text);
errMsg = (j.error && j.error.message) || errMsg;
errMsg = (j.message) || (j.error && j.error.message) || errMsg;
} catch (e) {}
return { json: null, text: "AI ошибка HTTP " + status + " (" + model + "): " + errMsg, tokens: 0, error: true };
}
const data = JSON.parse(text);
const responseText = (data.content || []).map(c => c.text || "").join("");
const tokens = (data.usage && (data.usage.input_tokens + data.usage.output_tokens)) || 0;
const choice = (data.choices && data.choices[0]) || {};
const responseText = (choice.message && choice.message.content) || "";
const tokens = (data.usage && data.usage.total_tokens) || 0;
// Иногда модель оборачивает JSON в ```json ... ```
let json = null;
try { json = JSON.parse(responseText); } catch (e) {}
try { json = JSON.parse(responseText); } catch (e) {
const stripped = responseText.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
try { json = JSON.parse(stripped); } catch (e2) {}
}
return { json, text: responseText, tokens };
}
@ -702,9 +766,12 @@ function seedAdminAsManager() {
}
/** Тестовый прогон Claude API: проверяет что ключ работает. */
function testClaude() {
const ai = callClaude("Скажи одной фразой: что за фабрика ЗОВ?");
return { ok: !ai.error, response_text: (ai.text || "").slice(0, 500), tokens: ai.tokens, model: PropertiesService.getScriptProperties().getProperty("ANTHROPIC_MODEL") || "claude-haiku-4-5-20251001" };
function testClaude() { return testAI(); } // legacy alias
function testAI() {
const ai = callAI("Скажи одной фразой: что за фабрика ЗОВ?");
const model = PropertiesService.getScriptProperties().getProperty("GIGACHAT_MODEL") || "GigaChat-Pro";
return { ok: !ai.error, provider: "GigaChat", model, response_text: (ai.text || "").slice(0, 500), tokens: ai.tokens };
}
/** Тест отправки сообщения через бота — пришлёт админу «привет». */