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:
@@ -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,12 +235,18 @@ 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
|
||||||
userId: session.user.id,
|
? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId })
|
||||||
content: mesaj,
|
: await spendCreditForMessage({
|
||||||
clientMessageId,
|
userId: session.user.id,
|
||||||
});
|
content: mesaj,
|
||||||
|
clientMessageId,
|
||||||
|
});
|
||||||
if (!harcama.ok) {
|
if (!harcama.ok) {
|
||||||
if (harcama.error === "INSUFFICIENT") {
|
if (harcama.error === "INSUFFICIENT") {
|
||||||
await krediBittiIsaretle(session.user.id);
|
await krediBittiIsaretle(session.user.id);
|
||||||
@@ -302,12 +326,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!anahtarVar()) {
|
if (!anahtarVar()) {
|
||||||
await grantCredits({
|
if (!bedelsiz) {
|
||||||
userId: session.user.id,
|
await grantCredits({
|
||||||
delta: 1,
|
userId: session.user.id,
|
||||||
reason: "refund",
|
delta: 1,
|
||||||
refId: kullaniciMesajId,
|
reason: "refund",
|
||||||
});
|
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,12 +397,14 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
await grantCredits({
|
if (!bedelsiz) {
|
||||||
userId,
|
await grantCredits({
|
||||||
delta: 1,
|
userId,
|
||||||
reason: "refund",
|
delta: 1,
|
||||||
refId: kullaniciMesajId,
|
reason: "refund",
|
||||||
}).catch(() => {});
|
refId: kullaniciMesajId,
|
||||||
|
}).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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user