From 70423853f35f39efe4d677ee42bfe19646d6c82c Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Fri, 25 Sep 2026 10:44:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(danisman):=20profil=20onay=C4=B1=20kredi?= =?UTF-8?q?=20harcamaz;=20yapay=20zek=C3=A2=20sa=C4=9Flay=C4=B1c=C4=B1=20a?= =?UTF-8?q?dresi=20.env'den=20(AI=5FBASE=5FURL/AI=5FAPI=5FKEY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bilal kararı (26 Eyl 2026): 'kaydedeyim mi?' sorusuna kısa evet/hayır cevabı bedelsiz. Sunucu son danışman mesajında onay ibaresini arar, mesaj ≤40 karakter ve evet/hayır kalıbıyla başlıyorsa kredi düşmez, ledger'a yazılmaz, iade yolu atlanır; X-Kredi-Dusuldu başlığıyla istemci bakiyeyi buna göre günceller. Gerçek soru içeren 'evet ama …' bedava geçmez. - Bilal kararı: model Türkiye'de barındırılacak. client.ts baseURL ve anahtar AI_BASE_URL/AI_API_KEY'den okunur (OPENROUTER_* geriye uyumlu); sağlayıcı değişince kod değil .env değişir, /gizlilik aynı commit'te. Co-Authored-By: Claude Fable 5.1 --- src/app/api/soru/route.ts | 72 +++++++++++++------ .../rapor/components/sohbet-client.tsx | 3 +- src/lib/ai/client.ts | 36 +++++++--- src/lib/credits.ts | 27 +++++++ 4 files changed, 108 insertions(+), 30 deletions(-) diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index ac805b7..6c39354 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -4,6 +4,7 @@ import { getSession } from "@/lib/session"; import { appDb, schema } from "@/lib/appdb"; import { spendCreditForMessage, + bedelsizMesajKaydet, grantCredits, krediBittiIsaretle, } from "@/lib/credits"; @@ -28,6 +29,22 @@ const MAX_GECMIS = 12; * C.3/10). Bedeli: "evet" cevabı bir kredi. Kapatılırsa model doğrudan kaydeder. */ const PROFIL_ONAY_SOR = true; + +/** Danışmanın onay sorusunda geçen sabit ibare; istemci de bunu görmez, yalnız sunucu arar. */ +const ONAY_SORUSU = "profiline kaydedeyim mi"; + +/** + * Onay mesajı mı: son danışman mesajı "kaydedeyim mi?" diye sormuş VE aday + * kısa bir evet/hayır yazmış. Bu mesaj kredi harcamaz (Bilal, 26 Eyl 2026). + * Kısa tutulur ki "evet ama İzmir'de hukuk kaç sırayla alıyor" gibi gerçek + * sorular bedava geçmesin. + */ +function onayMesajiMi(mesaj: string, sonAsistan: string | undefined): boolean { + if (!sonAsistan || !sonAsistan.toLocaleLowerCase("tr-TR").includes(ONAY_SORUSU)) return false; + const m = mesaj.trim().toLocaleLowerCase("tr-TR"); + if (m.length > 40) return false; + return /^(evet|olur|tamam|kaydet|kaydedebilirsin|kaydedelim|hayır|hayir|kaydetme|gerek yok|istemiyorum|şimdilik hayır)\b/.test(m); +} const MAX_MESAJ_UZUNLUK = 2000; function sistemPromptu(opts: { @@ -71,10 +88,11 @@ KURALLAR: - PROFİL: aday bir tercih söylerse (yaşadığı il, evden en fazla kaç km, vakıfta yıllık ücret sınırı, burs beklentisi, öğretim dili, uzaktan/açık kabul mü, kampüs büyüklüğü) ${PROFIL_ONAY_SOR - ? `HEMEN KAYDETME: cevabının sonunda tek cümleyle sor: "Bunu profiline - kaydedeyim mi? (Listen ve aramalar buna göre süzülür; istediğin zaman - silebilirsin.)" Aday evet/kaydet derse o zaman profil_guncelle ile kaydet - ve "Kaydettim: …" de. Aday açıkça "kaydet" diye yazdıysa sormadan kaydet.` + ? `HEMEN KAYDETME: cevabının sonunda tek cümleyle, tam bu ibareyle sor: + "Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz; listen ve + aramalar buna göre süzülür, istediğin zaman silebilirsin.)" Aday + evet/kaydet derse o zaman profil_guncelle ile kaydet ve "Kaydettim: …" de. + Aday açıkça "kaydet" diye yazdıysa sormadan kaydet.` : `profil_guncelle ile kaydet ve cevabının sonunda tek cümleyle söyle: "Şunu kaydettim: …". Emin değilsen önce sor, tahminle kaydetme.`} Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma: @@ -217,12 +235,18 @@ export async function POST(request: NextRequest) { } const gecmis = gecmisHam.reverse(); + // Profil onayı ("kaydedeyim mi?" → "evet") kredi harcamaz; iade de yok. + const sonAsistan = [...gecmis].reverse().find((m) => m.role === "assistant")?.content; + const bedelsiz = onayMesajiMi(mesaj, sonAsistan); + // Kredi düşümü stream'den önce — yetersizse hiç başlama - const harcama = await spendCreditForMessage({ - userId: session.user.id, - content: mesaj, - clientMessageId, - }); + const harcama = bedelsiz + ? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId }) + : await spendCreditForMessage({ + userId: session.user.id, + content: mesaj, + clientMessageId, + }); if (!harcama.ok) { if (harcama.error === "INSUFFICIENT") { await krediBittiIsaretle(session.user.id); @@ -302,12 +326,14 @@ export async function POST(request: NextRequest) { } if (!anahtarVar()) { - await grantCredits({ - userId: session.user.id, - delta: 1, - reason: "refund", - refId: kullaniciMesajId, - }); + if (!bedelsiz) { + await grantCredits({ + userId: session.user.id, + delta: 1, + reason: "refund", + refId: kullaniciMesajId, + }); + } console.error("[soru] Yapay Zeka anahtarı tanımlı değil"); return Response.json( { @@ -371,12 +397,14 @@ export async function POST(request: NextRequest) { }); } catch {} } - await grantCredits({ - userId, - delta: 1, - reason: "refund", - refId: kullaniciMesajId, - }).catch(() => {}); + if (!bedelsiz) { + await grantCredits({ + userId, + delta: 1, + reason: "refund", + refId: kullaniciMesajId, + }).catch(() => {}); + } } console.error("[soru] stream hatası:", err); controller.error(err); @@ -389,6 +417,8 @@ export async function POST(request: NextRequest) { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", + // İstemci bakiyeyi buna göre düşürür (onay mesajı bedelsiz) + "X-Kredi-Dusuldu": bedelsiz ? "0" : "1", }, }); } diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index 2da7a06..367f4b7 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -269,7 +269,8 @@ export function SohbetClient({ return; } - setKredi((k) => k - 1); + // Profil onayı ("kaydedeyim mi?" → "evet") sunucuda bedelsizdir + if (res.headers.get("X-Kredi-Dusuldu") !== "0") setKredi((k) => k - 1); // Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx) olay("danisman_sorusu", { sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc", diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index 953ff9d..bb2b835 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -1,9 +1,29 @@ import OpenAI from "openai"; -// Tüm AI akışı OpenRouter üzerinden tek kapıdan gider (kullanıcı kararı, -// 7 Ağu 2026 — önceki Anthropic/Gemini/lokal sağlayıcı seçimi kaldırıldı). -// Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir. +// Tüm AI akışı OpenAI uyumlu TEK kapıdan gider. Varsayılan OpenRouter +// (7 Ağu 2026 kararı); 26 Eyl 2026 kararıyla model Türkiye'de barındırılacak: +// sağlayıcı değişince kod değil .env değişir — AI_BASE_URL + AI_API_KEY +// (OpenRouter için eski OPENROUTER_* adları da çalışır). Sağlayıcı değişirse +// /gizlilik "Hizmet sağlayıcılar" bölümü aynı commit'te güncellenir. const VARSAYILAN_MODEL = "deepseek/deepseek-v4-flash"; +const VARSAYILAN_BASE_URL = "https://openrouter.ai/api/v1"; + +function baseUrl(): string { + return process.env.AI_BASE_URL?.trim() || VARSAYILAN_BASE_URL; +} + +function apiKey(): string | undefined { + return process.env.AI_API_KEY?.trim() || process.env.OPENROUTER_API_KEY?.trim(); +} + +/** OpenRouter dışı bir sağlayıcı tanımlı mı (gizlilik metni ve log için). */ +export function saglayiciAdi(): string { + try { + return new URL(baseUrl()).hostname; + } catch { + return "bilinmiyor"; + } +} export function raporModeli(): string { return ( @@ -23,20 +43,20 @@ export function sohbetModeli(): string { /** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */ export function anahtarVar(): boolean { - return Boolean(process.env.OPENROUTER_API_KEY); + return Boolean(apiKey()); } const globalForAi = globalThis as unknown as { __openrouter?: OpenAI }; export function getOpenRouter(): OpenAI { - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { + const key = apiKey(); + if (!key) { throw new Error("AI_KEY_MISSING"); } if (!globalForAi.__openrouter) { globalForAi.__openrouter = new OpenAI({ - baseURL: "https://openrouter.ai/api/v1", - apiKey, + baseURL: baseUrl(), + apiKey: key, // Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla maxRetries: 0, // OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel) diff --git a/src/lib/credits.ts b/src/lib/credits.ts index 2683b5c..465d64b 100644 --- a/src/lib/credits.ts +++ b/src/lib/credits.ts @@ -137,6 +137,33 @@ export async function spendCreditForMessage(opts: { } } +/** + * Kredi DÜŞMEDEN kullanıcı mesajı kaydeder — yalnız profil onayı ("kaydedeyim + * mi?" → "evet") için (Bilal kararı, 26 Eyl 2026: onay ücretsiz). Ledger'a + * satır yazılmaz; clientMessageId çakışırsa DUPLICATE. + */ +export async function bedelsizMesajKaydet(opts: { + userId: string; + content: string; + clientMessageId: string; +}): Promise { + const messageId = newId(); + try { + await appDb.insert(chatMessages).values({ + id: messageId, + userId: opts.userId, + role: "user", + content: opts.content, + clientMessageId: opts.clientMessageId, + createdAt: new Date(), + }); + return { ok: true, messageId }; + } catch (err) { + if (uniqueIhlaliMi(err)) return { ok: false, error: "DUPLICATE" }; + throw err; + } +} + /** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */ class ZatenPaketli extends Error {}