Update dependencies, enhance AI integration, and improve email content
Some checks failed
Deploy / deploy (push) Failing after 1h35m43s

- Added @google/genai dependency for enhanced AI capabilities.
- Updated CI workflow to include new environment variables for AI integration.
- Refactored AI client to support multiple providers and improved key management.
- Enhanced email templates for better user engagement and clarity.
- Updated error handling in AI-related functions to provide clearer feedback.
- Made various improvements to the application structure and code clarity.
This commit is contained in:
bilalgursen
2026-08-02 21:37:25 +03:00
parent 2278d2a218
commit c2aba25814
12 changed files with 548 additions and 98 deletions

158
src/lib/ai/cagri.ts Normal file
View File

@@ -0,0 +1,158 @@
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { ThinkingLevel } from "@google/genai";
import {
getAnthropic,
getGemini,
raporModeli,
saglayici,
sohbetModeli,
} from "./client";
/**
* Sağlayıcıdan bağımsız AI çağrı katmanı. Anthropic ve Gemini'nin SDK'ları
* yapılandırılmış çıktı ve stream için farklı şekiller kullanıyor; rapor ve
* sohbet kodu bu farkı görmesin diye tek arayüz burada toplanıyor.
*/
/**
* Gemini JSON Schema'nın yalnızca bir alt kümesini destekliyor; zod'un ürettiği
* $schema ve additionalProperties alanları isteği reddettiriyor, ayıklanıyor.
*/
function geminiSemasi(sema: z.ZodType): unknown {
const temizle = (dugum: unknown): unknown => {
if (Array.isArray(dugum)) return dugum.map(temizle);
if (dugum && typeof dugum === "object") {
const cikti: Record<string, unknown> = {};
for (const [k, v] of Object.entries(dugum as Record<string, unknown>)) {
if (k === "$schema" || k === "additionalProperties") continue;
cikti[k] = temizle(v);
}
return cikti;
}
return dugum;
};
return temizle(z.toJSONSchema(sema));
}
/**
* Gemini ücretsiz katmanı yoğunlukta 503/429 döndürüyor; bu geçici hatalarda
* artan bekleme ile yeniden dene. Kalıcı hatalar (400, 403) olduğu gibi fırlar.
*/
const GECICI_KODLAR = new Set([429, 500, 502, 503, 504]);
async function geciciHatadaTekrarla<T>(
islem: () => Promise<T>,
denemeSayisi = 3,
): Promise<T> {
let sonHata: unknown;
for (let i = 0; i < denemeSayisi; i++) {
try {
return await islem();
} catch (hata) {
const kod = (hata as { status?: number })?.status;
if (kod == null || !GECICI_KODLAR.has(kod) || i === denemeSayisi - 1) {
throw hata;
}
sonHata = hata;
await new Promise((r) => setTimeout(r, 2000 * 2 ** i));
}
}
throw sonHata;
}
/**
* Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner
* (çağıran taraf yeniden dener).
*/
export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
system: string;
kullanici: string;
sema: T;
maxTokens: number;
}): Promise<z.infer<T> | null> {
if (saglayici() === "gemini") {
const yanit = await geciciHatadaTekrarla(() =>
getGemini().models.generateContent({
model: raporModeli(),
contents: opts.kullanici,
config: {
systemInstruction: opts.system,
maxOutputTokens: opts.maxTokens,
responseMimeType: "application/json",
responseJsonSchema: geminiSemasi(opts.sema),
thinkingConfig: { thinkingLevel: ThinkingLevel.HIGH },
},
}),
);
const metin = yanit.text;
if (!metin) return null;
try {
const parsed = opts.sema.safeParse(JSON.parse(metin));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
const yanit = await getAnthropic().messages.parse({
model: raporModeli(),
max_tokens: opts.maxTokens,
thinking: { type: "adaptive" },
system: opts.system,
messages: [{ role: "user", content: opts.kullanici }],
output_config: { format: zodOutputFormat(opts.sema) },
});
return yanit.parsed_output ?? null;
}
export type SohbetMesaji = { role: "user" | "assistant"; content: string };
/**
* Sohbet yanıtını parça parça üretir. Sağlayıcıya bakmaksızın düz metin
* parçaları yield eder; çağıran taraf hem stream'ler hem biriktirip kaydeder.
*/
export async function* sohbetAkisi(opts: {
system: string;
mesajlar: SohbetMesaji[];
maxTokens: number;
}): AsyncGenerator<string> {
if (saglayici() === "gemini") {
const akis = await geciciHatadaTekrarla(() =>
getGemini().models.generateContentStream({
model: sohbetModeli(),
contents: opts.mesajlar.map((m) => ({
// Gemini "assistant" yerine "model" rolünü kullanır
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
})),
config: {
systemInstruction: opts.system,
maxOutputTokens: opts.maxTokens,
// Sohbet kısa ve hızlı olmalı; uzun düşünme adayı bekletiyor
thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
},
}),
);
for await (const parca of akis) {
const metin = parca.text;
if (metin) yield metin;
}
return;
}
const akis = getAnthropic().messages.stream({
model: sohbetModeli(),
max_tokens: opts.maxTokens,
system: opts.system,
messages: opts.mesajlar,
});
for await (const olay of akis) {
if (
olay.type === "content_block_delta" &&
olay.delta.type === "text_delta"
) {
yield olay.delta.text;
}
}
}

View File

@@ -1,11 +1,44 @@
import Anthropic from "@anthropic-ai/sdk";
import { GoogleGenAI } from "@google/genai";
// Model seçimi (bilinçli): rapor tek seferlik ve yüksek değerli -> Opus 4.8;
export type Saglayici = "anthropic" | "gemini";
/** AI_SAGLAYICI env'i ile seçilir; tanımsızsa hangi anahtar varsa o kullanılır. */
export function saglayici(): Saglayici {
const secim = process.env.AI_SAGLAYICI?.trim().toLowerCase();
if (secim === "gemini" || secim === "anthropic") return secim;
return process.env.GEMINI_API_KEY ? "gemini" : "anthropic";
}
/** Seçili sağlayıcının anahtarı tanımlı mı (mock moduna düşülecek mi)? */
export function anahtarVar(): boolean {
return saglayici() === "gemini"
? Boolean(process.env.GEMINI_API_KEY)
: Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
}
// Model seçimi (bilinçli):
// Anthropic — rapor tek seferlik ve yüksek değerli -> Opus 4.8;
// sohbet kredi başına marj hassas -> Haiku 4.5 (kullanıcı kararı, 22 Tem 2026).
export const RAPOR_MODEL = "claude-opus-4-8";
export const SOHBET_MODEL = "claude-haiku-4-5";
// Gemini — ücretsiz katmanda yalnızca Flash ailesi açık (Pro'da kota 0),
// bu yüzden rapor ve sohbet aynı modeli kullanır; ayrım thinking seviyesinde.
const MODELLER: Record<Saglayici, { rapor: string; sohbet: string }> = {
anthropic: { rapor: "claude-opus-4-8", sohbet: "claude-haiku-4-5" },
gemini: { rapor: "gemini-3.6-flash", sohbet: "gemini-3.6-flash" },
};
const globalForAi = globalThis as unknown as { __anthropic?: Anthropic };
export function raporModeli(): string {
return MODELLER[saglayici()].rapor;
}
export function sohbetModeli(): string {
return MODELLER[saglayici()].sohbet;
}
const globalForAi = globalThis as unknown as {
__anthropic?: Anthropic;
__gemini?: GoogleGenAI;
};
export function getAnthropic(): Anthropic {
if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) {
@@ -17,5 +50,16 @@ export function getAnthropic(): Anthropic {
return globalForAi.__anthropic;
}
export function getGemini(): GoogleGenAI {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("AI_KEY_MISSING");
}
if (!globalForAi.__gemini) {
globalForAi.__gemini = new GoogleGenAI({ apiKey });
}
return globalForAi.__gemini;
}
export const SORUMLULUK_REDDI =
"KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine dayanır ancak yerleşme garantisi verilmez. Nihai tercih sorumluluğu adaya aittir.";

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { getAnthropic, RAPOR_MODEL } from "./client";
import { anahtarVar } from "./client";
import { yapilandirilmisUret } from "./cagri";
import {
havuzOlustur,
havuzuKompaktJson,
@@ -88,11 +88,7 @@ function programBilgisi(p: AdayProgram): RaporSonuc["programlar"][string] {
// gerçek Yapay Zeka olmadan uçtan uca test edilebilsin diye. Prod'da asla çalışmaz.
function mockAktif(): boolean {
return (
process.env.NODE_ENV !== "production" &&
!process.env.ANTHROPIC_API_KEY &&
!process.env.ANTHROPIC_AUTH_TOKEN
);
return process.env.NODE_ENV !== "production" && !anahtarVar();
}
function mockRapor(
@@ -140,7 +136,7 @@ function mockRapor(
return {
tercihler,
genelDegerlendirme: `(Dev mock — ANTHROPIC_API_KEY tanımlı değil, bu rapor gerçek Yapay Zeka çıktısı değildir.) ${params.sira.toLocaleString(
genelDegerlendirme: `(Dev mock — Yapay Zeka anahtarı tanımlı değil, bu rapor gerçek Yapay Zeka çıktısı değildir.) ${params.sira.toLocaleString(
"tr-TR",
)}. sıra için hayal/dengeli/garanti dağılımlı 24 tercihlik örnek liste kuruldu.${
revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : ""
@@ -189,7 +185,6 @@ export async function raporUret(
return mockRapor(havuz, params, revizyonNotu);
}
const client = getAnthropic();
const havuzJson = havuzuKompaktJson(havuz);
const kullaniciMesaji = [
`Aday bilgisi: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${turLabel(params.tur)}.`,
@@ -215,24 +210,16 @@ export async function raporUret(
let sonuc: z.infer<typeof RaporSchema> | null = null;
let sonHata = "";
for (let deneme = 0; deneme < 2; deneme++) {
const response = await client.messages.parse({
model: RAPOR_MODEL,
max_tokens: 16000,
thinking: { type: "adaptive" },
const parsed = await yapilandirilmisUret({
system: SISTEM,
messages: [
{
role: "user",
content:
deneme === 0
? kullaniciMesaji
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
},
],
output_config: { format: zodOutputFormat(RaporSchema) },
kullanici:
deneme === 0
? kullaniciMesaji
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
sema: RaporSchema,
maxTokens: 16000,
});
const parsed = response.parsed_output;
if (!parsed) {
sonHata = "Çıktı şemaya uymadı.";
continue;