feat(danisman): profil onayı kredi harcamaz; yapay zekâ sağlayıcı adresi .env'den (AI_BASE_URL/AI_API_KEY)

- 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 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-25 10:44:44 +03:00
parent 5a04db7e4b
commit 70423853f3
4 changed files with 108 additions and 30 deletions

View File

@@ -4,6 +4,7 @@ import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
import { import {
spendCreditForMessage, spendCreditForMessage,
bedelsizMesajKaydet,
grantCredits, grantCredits,
krediBittiIsaretle, krediBittiIsaretle,
} from "@/lib/credits"; } 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. * C.3/10). Bedeli: "evet" cevabı bir kredi. Kapatılırsa model doğrudan kaydeder.
*/ */
const PROFIL_ONAY_SOR = true; 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; const MAX_MESAJ_UZUNLUK = 2000;
function sistemPromptu(opts: { function sistemPromptu(opts: {
@@ -71,10 +88,11 @@ KURALLAR:
- PROFİL: aday bir tercih söylerse (yaşadığı il, evden en fazla kaç km, - 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 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 kabul mü, kampüs büyüklüğü) ${PROFIL_ONAY_SOR
? `HEMEN KAYDETME: cevabının sonunda tek cümleyle sor: "Bunu profiline ? `HEMEN KAYDETME: cevabının sonunda tek cümleyle, tam bu ibareyle sor:
kaydedeyim mi? (Listen ve aramalar buna göre süzülür; istediğin zaman "Bunu profiline kaydedeyim mi? (Evet demek kredi harcamaz; listen ve
silebilirsin.)" Aday evet/kaydet derse o zaman profil_guncelle ile kaydet aramalar buna göre süzülür, istediğin zaman silebilirsin.)" Aday
ve "Kaydettim: …" de. Aday açıkça "kaydet" diye yazdıysa sormadan kaydet.` 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: : `profil_guncelle ile kaydet ve cevabının sonunda tek cümleyle söyle:
"Şunu kaydettim: …". Emin değilsen önce sor, tahminle kaydetme.`} "Şunu kaydettim: …". Emin değilsen önce sor, tahminle kaydetme.`}
Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma: Bunların DIŞINDA hiçbir şeyi kaydetme ve sorma:
@@ -217,8 +235,14 @@ export async function POST(request: NextRequest) {
} }
const gecmis = gecmisHam.reverse(); 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 // Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = await spendCreditForMessage({ const harcama = bedelsiz
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId })
: await spendCreditForMessage({
userId: session.user.id, userId: session.user.id,
content: mesaj, content: mesaj,
clientMessageId, clientMessageId,
@@ -302,12 +326,14 @@ export async function POST(request: NextRequest) {
} }
if (!anahtarVar()) { if (!anahtarVar()) {
if (!bedelsiz) {
await grantCredits({ await grantCredits({
userId: session.user.id, userId: session.user.id,
delta: 1, delta: 1,
reason: "refund", reason: "refund",
refId: kullaniciMesajId, refId: kullaniciMesajId,
}); });
}
console.error("[soru] Yapay Zeka anahtarı tanımlı değil"); console.error("[soru] Yapay Zeka anahtarı tanımlı değil");
return Response.json( return Response.json(
{ {
@@ -371,6 +397,7 @@ export async function POST(request: NextRequest) {
}); });
} catch {} } catch {}
} }
if (!bedelsiz) {
await grantCredits({ await grantCredits({
userId, userId,
delta: 1, delta: 1,
@@ -378,6 +405,7 @@ export async function POST(request: NextRequest) {
refId: kullaniciMesajId, refId: kullaniciMesajId,
}).catch(() => {}); }).catch(() => {});
} }
}
console.error("[soru] stream hatası:", err); console.error("[soru] stream hatası:", err);
controller.error(err); controller.error(err);
} }
@@ -389,6 +417,8 @@ export async function POST(request: NextRequest) {
"Content-Type": "text/plain; charset=utf-8", "Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store", "Cache-Control": "no-store",
// İstemci bakiyeyi buna göre düşürür (onay mesajı bedelsiz)
"X-Kredi-Dusuldu": bedelsiz ? "0" : "1",
}, },
}); });
} }

View File

@@ -269,7 +269,8 @@ export function SohbetClient({
return; 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) // Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
olay("danisman_sorusu", { olay("danisman_sorusu", {
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc", sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",

View File

@@ -1,9 +1,29 @@
import OpenAI from "openai"; import OpenAI from "openai";
// Tüm AI akışı OpenRouter üzerinden tek kapıdan gider (kullanıcı kararı, // Tüm AI akışı OpenAI uyumlu TEK kapıdan gider. Varsayılan OpenRouter
// 7 Ağu 2026 — önceki Anthropic/Gemini/lokal sağlayıcı seçimi kaldırıldı). // (7 Ağu 2026 kararı); 26 Eyl 2026 kararıyla model Türkiye'de barındırılacak:
// Model .env'den değiştirilebilir; rapor ve sohbet istenirse ayrışabilir. // 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_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 { export function raporModeli(): string {
return ( return (
@@ -23,20 +43,20 @@ export function sohbetModeli(): string {
/** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */ /** API anahtarı tanımlı mı (mock moduna düşülecek mi)? */
export function anahtarVar(): boolean { export function anahtarVar(): boolean {
return Boolean(process.env.OPENROUTER_API_KEY); return Boolean(apiKey());
} }
const globalForAi = globalThis as unknown as { __openrouter?: OpenAI }; const globalForAi = globalThis as unknown as { __openrouter?: OpenAI };
export function getOpenRouter(): OpenAI { export function getOpenRouter(): OpenAI {
const apiKey = process.env.OPENROUTER_API_KEY; const key = apiKey();
if (!apiKey) { if (!key) {
throw new Error("AI_KEY_MISSING"); throw new Error("AI_KEY_MISSING");
} }
if (!globalForAi.__openrouter) { if (!globalForAi.__openrouter) {
globalForAi.__openrouter = new OpenAI({ globalForAi.__openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1", baseURL: baseUrl(),
apiKey, apiKey: key,
// Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla // Tek retry otoritesi cagri.ts'teki geciciHatadaTekrarla
maxRetries: 0, maxRetries: 0,
// OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel) // OpenRouter panelindeki kullanım dökümünde uygulama atfı (opsiyonel)

View File

@@ -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<SpendResult & { messageId?: string }> {
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. */ /** `paketTekil` ile çağrılan tanımlamada kullanıcı zaten paketliyse fırlatılır. */
class ZatenPaketli extends Error {} class ZatenPaketli extends Error {}