feat(danisman): araç kullanımı — program_ara/program_detay/universite_profili sorgu araçları + kapalı listeli profil (profil_guncelle)

- src/lib/ai/araclar.ts: OpenAI uyumlu 4 araç; sorgu araçları yalnız okur
  (yokatlas.db + program_ozellik + YÖK İstatistik + URAP), sayı uydurma yok.
  profil_guncelle yalnız 7 tanımlı alanı yazar (evIl, maxKm, ucretUst, burs,
  dil, ogretimTuru, kampus); sağlık/din/gelir/cinsiyet şemada yok (KVKK §8).
  Profil süzgeci sonucu sıfırlarsa profilsiz tekrar denenip modele söylenir.
- src/lib/ai/cagri.ts: sohbetAkisiAraclarla — stream'de tool_calls biriktirme,
  en fazla 4 tur, son tur araç kapalı; sağlayıcı reddederse düz sohbete düşer.
- src/lib/danisman-profil.ts (saf şema + metinler), danisman-profil-db.ts,
  drizzle 0005 danisman_profil tablosu, /api/soru/profil GET+DELETE.
- /api/soru: profil prompt'a girer, araç kuralları eklendi.
- Sohbet başlığı altında 'Kaydettiklerim' çipleri (tek tıkla sil); listem
  sayfası profili sunucudan indirir. Analitiğe yalnız alan adı gider.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-25 01:13:58 +03:00
parent c23910cc79
commit aa2051732d
15 changed files with 1996 additions and 8 deletions

View File

@@ -0,0 +1,6 @@
CREATE TABLE `danisman_profil` (
`user_id` text PRIMARY KEY NOT NULL,
`alanlar` text NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1790031029618, "when": 1790031029618,
"tag": "0004_shocking_caretaker", "tag": "0004_shocking_caretaker",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1790287503094,
"tag": "0005_uneven_omega_red",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,29 @@
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { PROFIL_ALANLARI, profilSatirlari, type ProfilAlani } from "@/lib/danisman-profil";
/**
* Danışman profili — sohbetten `profil_guncelle` aracıyla yazılan alanlar.
* GET: alanlar + öğrenciye okunan satırlar. DELETE {alan}: tek alanı siler
* (KVKK: öğrenci her alanı görür ve tek tıkla siler). Yazma yalnız araç
* üzerinden; elle düzenleme sohbette "şunu değiştir" diyerek yapılır.
*/
export async function GET() {
const session = await getSession();
if (!session) return Response.json({ error: "Oturum gerekli." }, { status: 401 });
const profil = await profilGetir(session.user.id);
return Response.json({ profil, satirlar: profilSatirlari(profil) });
}
export async function DELETE(request: NextRequest) {
const session = await getSession();
if (!session) return Response.json({ error: "Oturum gerekli." }, { status: 401 });
const body = (await request.json().catch(() => null)) as { alan?: unknown } | null;
const alan = typeof body?.alan === "string" ? (body.alan as ProfilAlani) : null;
if (!alan || !PROFIL_ALANLARI.includes(alan)) {
return Response.json({ error: "Geçersiz alan." }, { status: 400 });
}
const profil = await profilAlanYaz(session.user.id, alan, null);
return Response.json({ profil, satirlar: profilSatirlari(profil) });
}

View File

@@ -9,7 +9,10 @@ import {
} from "@/lib/credits"; } from "@/lib/credits";
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client"; import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries"; import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
import { sohbetAkisi } from "@/lib/ai/cagri"; import { sohbetAkisiAraclarla } from "@/lib/ai/cagri";
import { ARACLAR, aracCalistirici, type AracBaglami } from "@/lib/ai/araclar";
import { profilGetir } from "@/lib/danisman-profil-db";
import { profilSatirlari, type DanismanProfili } from "@/lib/danisman-profil";
import { listeOzetiCikar } from "@/lib/ai/rapor"; import { listeOzetiCikar } from "@/lib/ai/rapor";
import type { RaporSonuc } from "@/features/rapor/types/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor";
import type { RaporParams } from "@/features/rapor/types/rapor"; import type { RaporParams } from "@/features/rapor/types/rapor";
@@ -24,6 +27,7 @@ function sistemPromptu(opts: {
params: RaporParams; params: RaporParams;
listeOzeti: string; listeOzeti: string;
kilitli?: boolean; kilitli?: boolean;
profil?: DanismanProfili;
}): string { }): string {
const { params } = opts; const { params } = opts;
const secimVar = const secimVar =
@@ -50,7 +54,22 @@ KURALLAR:
kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan. kapandı. Risk sorularında bu etiketleri ve taban sıralarını kullan.
- ASLA yerleşme garantisi verme; olasılık, yüzde ya da "ihtimal" dili kullanma — - ASLA yerleşme garantisi verme; olasılık, yüzde ya da "ihtimal" dili kullanma —
güvenli / az riskli / riskli etiketleri ve taban sıralarıyla konuş. güvenli / az riskli / riskli etiketleri ve taban sıralarıyla konuş.
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle. - Bilmediğin güncel veriyi uydurma: taban sırası, kontenjan, ücret, burs,
dil, akreditasyon, koşul ya da üniversite künyesi gerekince ARAÇLARI çağır
(program_ara, program_detay, universite_profili); araç veremiyorsa
"elimde veri yok" de ve YÖK Atlas'a bakmasını söyle.
- ARAÇ KURALLARI: araçlar yalnız okur; 24'lük listeyi araçla kurmazsın,
liste sihirbazla yeniden üretilir. Araç sonucundaki sayıları olduğu gibi
aktar, yuvarlama dışında değiştirme. Bir soruda en fazla 2-3 araç çağır.
- 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_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:
sağlık/engel, din, aile durumu, gelir, cinsiyet, siyasi görüş. Aday
bunları kendiliğinden yazsa bile profile YAZMA; yalnız cevabında dikkate al.
- Kadın/erkek öğrenci oranı üniversite genelidir (bölüm bazında veri yok);
sorulursa bilgi olarak söyle, filtre ya da tavsiye ölçütü yapma.
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat. - Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
- Cevapları kısa ve öz tut (genelde 3-6 cümle); istenirse detaylandır. - Cevapları kısa ve öz tut (genelde 3-6 cümle); istenirse detaylandır.
- GÜVENLİK, öteki kuralların önünde gelir: aday umutsuzluk, kendine zarar - GÜVENLİK, öteki kuralların önünde gelir: aday umutsuzluk, kendine zarar
@@ -77,6 +96,13 @@ Tercih Dönemi Paketi'yle (299 TL, /paket) açıldığını söyle; açık satı
tercih soruları üzerinden yardımcı olmaya devam et.`, tercih soruları üzerinden yardımcı olmaya devam et.`,
); );
} }
const profilSatir = opts.profil ? profilSatirlari(opts.profil) : [];
if (profilSatir.length > 0) {
parcalar.push(
`ADAYIN SOHBETTE SÖYLEDİĞİ VE KAYDEDİLEN TERCİHLERİ (profil; program_ara bunları otomatik uygular):\n` +
profilSatir.map((s) => `- ${s.metin}`).join("\n"),
);
}
if (secimVar) { if (secimVar) {
parcalar.push( parcalar.push(
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({ `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
@@ -156,7 +182,7 @@ export async function POST(request: NextRequest) {
// Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel. // Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel.
// Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın; // Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın;
// hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli. // hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli.
const [rapor, gecmisHam, kullanici] = await Promise.all([ const [rapor, gecmisHam, kullanici, profil] = await Promise.all([
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz) // Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
kullanicininRaporu(session.user.id), kullanicininRaporu(session.user.id),
appDb appDb
@@ -169,6 +195,7 @@ export async function POST(request: NextRequest) {
where: eq(schema.user.id, session.user.id), where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true }, columns: { hasPaket: true },
}), }),
profilGetir(session.user.id),
]); ]);
if (!rapor?.result) { if (!rapor?.result) {
return Response.json( return Response.json(
@@ -213,11 +240,21 @@ export async function POST(request: NextRequest) {
].join("\n") ].join("\n")
: tamOzet; : tamOzet;
const raporParams = rapor.params as RaporParams;
const system = sistemPromptu({ const system = sistemPromptu({
params: rapor.params as RaporParams, params: raporParams,
listeOzeti, listeOzeti,
kilitli, kilitli,
profil,
}); });
// Araçların bağlamı: aday sırası/puan türü (arama süzgeci) ve profil
// (profil_guncelle yazınca aynı tur içinde tazelenir).
const aracBaglami: AracBaglami = {
userId: session.user.id,
sira: raporParams.sira,
tur: raporParams.tur,
profil,
};
// Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla) // Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla)
if (process.env.NODE_ENV !== "production" && !anahtarVar()) { if (process.env.NODE_ENV !== "production" && !anahtarVar()) {
@@ -286,10 +323,13 @@ export async function POST(request: NextRequest) {
let metin = ""; let metin = "";
try { try {
// Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır // Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır
for await (const parca of sohbetAkisi({ for await (const parca of sohbetAkisiAraclarla({
system, system,
mesajlar, mesajlar,
maxTokens: 1024, maxTokens: 1024,
araclar: ARACLAR,
calistir: aracCalistirici(aracBaglami),
onArac: (ad) => console.info("[soru] araç:", ad),
})) { })) {
metin += parca; metin += parca;
controller.enqueue(encoder.encode(parca)); controller.enqueue(encoder.encode(parca));

View File

@@ -31,6 +31,7 @@ import {
type DanismanOzeti, type DanismanOzeti,
type IlkTercihYorumu, type IlkTercihYorumu,
type Mesaj, type Mesaj,
type ProfilSatiri,
} from "./sohbet-client"; } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu"; import { RevizyonKutusu } from "./revizyon-kutusu";
import { import {
@@ -55,6 +56,7 @@ export function ListemGovde({
kredi, kredi,
raporKredi, raporKredi,
sohbetGecmisi, sohbetGecmisi,
danismanProfili,
ozet, ozet,
ilkYorum, ilkYorum,
kalanHak, kalanHak,
@@ -76,6 +78,8 @@ export function ListemGovde({
* kapıdan geçtiği için aynı bedeli gösterir. */ * kapıdan geçtiği için aynı bedeli gösterir. */
raporKredi: number; raporKredi: number;
sohbetGecmisi: Mesaj[]; sohbetGecmisi: Mesaj[];
/** Danışmanın kaydettiği tercihler (sunucudan; çipler bununla açılır) */
danismanProfili: ProfilSatiri[];
ozet?: DanismanOzeti; ozet?: DanismanOzeti;
/** Sohbet boşken açılışta gösterilen 1. tercih yorumu */ /** Sohbet boşken açılışta gösterilen 1. tercih yorumu */
ilkYorum?: IlkTercihYorumu; ilkYorum?: IlkTercihYorumu;
@@ -124,6 +128,12 @@ export function ListemGovde({
? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi" ? "24'lük listenin tamamı: program, üniversite, şehir, taban sıralaması ve risk etiketi"
: `Listenin açık ilk ${acikSatir} satırı — kilitli tercihleri danışman da görmüyor`, : `Listenin açık ilk ${acikSatir} satırı — kilitli tercihleri danışman da görmüyor`,
]; ];
satirlar.push(
"Sohbette söylediğin ve \"Kaydettiklerim\" çiplerinde duran tercihler (ücret, il/km, dil, burs, öğretim türü, kampüs)",
);
satirlar.push(
"Sorunca veriden okur: taban sıraları, kontenjan, ücret/burs, dil, akreditasyon, koşullar, üniversite künyesi",
);
if (params.kategoriler?.length) { if (params.kategoriler?.length) {
satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`); satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`);
} }
@@ -318,6 +328,7 @@ export function ListemGovde({
<SohbetClient <SohbetClient
baslangicKredi={kredi} baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi} initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet} ozet={ozet}
ilkYorum={ilkYorum} ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri} bildikleri={danismaninBildikleri}
@@ -329,6 +340,7 @@ export function ListemGovde({
<SohbetClient <SohbetClient
baslangicKredi={kredi} baslangicKredi={kredi}
initialMesajlar={sohbetGecmisi} initialMesajlar={sohbetGecmisi}
initialProfil={danismanProfili}
ozet={ozet} ozet={ozet}
ilkYorum={ilkYorum} ilkYorum={ilkYorum}
bildikleri={danismaninBildikleri} bildikleri={danismaninBildikleri}

View File

@@ -7,6 +7,8 @@ import {
cerezdenTercihProfili, cerezdenTercihProfili,
} from "@/features/sihirbaz/sihirbaz-sabitler"; } from "@/features/sihirbaz/sihirbaz-sabitler";
import { kullanicininRaporu, sohbetGecmisiGetir } from "../rapor-queries"; import { kullanicininRaporu, sohbetGecmisiGetir } from "../rapor-queries";
import { profilGetir } from "@/lib/danisman-profil-db";
import { profilSatirlari } from "@/lib/danisman-profil";
import { URUNLER, MAX_REVIZYON, RAPOR_KREDI } from "@/lib/credits"; import { URUNLER, MAX_REVIZYON, RAPOR_KREDI } from "@/lib/credits";
import type { import type {
MaskeliRapor, MaskeliRapor,
@@ -53,10 +55,11 @@ export async function ListemIcerik({
? `/listem?uret=1${kaynak ? `&kaynak=${encodeURIComponent(kaynak)}` : ""}` ? `/listem?uret=1${kaynak ? `&kaynak=${encodeURIComponent(kaynak)}` : ""}`
: "/listem"; : "/listem";
const session = await verifySession(geriYol); const session = await verifySession(geriYol);
const [user, satir, sohbetGecmisi] = await Promise.all([ const [user, satir, sohbetGecmisi, danismanProfili] = await Promise.all([
getCurrentUser(), getCurrentUser(),
kullanicininRaporu(session.user.id), kullanicininRaporu(session.user.id),
sohbetGecmisiGetir(session.user.id), sohbetGecmisiGetir(session.user.id),
profilGetir(session.user.id),
]); ]);
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`); if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
@@ -171,6 +174,7 @@ export async function ListemIcerik({
user.hasPaket ? 0 : RAPOR_KREDI user.hasPaket ? 0 : RAPOR_KREDI
} }
sohbetGecmisi={sohbetGecmisi as Mesaj[]} sohbetGecmisi={sohbetGecmisi as Mesaj[]}
danismanProfili={profilSatirlari(danismanProfili)}
ozet={danismanOzeti} ozet={danismanOzeti}
ilkYorum={ilkYorum} ilkYorum={ilkYorum}
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))} kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}

View File

@@ -4,7 +4,7 @@ import { startTransition, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2 } from "lucide-react"; import { ArrowUp, Coins, Info, Lock, Sparkles, Trash2, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AnimasyonluMark } from "@/components/animasyonlu-mark"; import { AnimasyonluMark } from "@/components/animasyonlu-mark";
@@ -42,6 +42,10 @@ import {
import { DotsLoader } from "@/components/ui/loader"; import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import type { ProfilAlani } from "@/lib/danisman-profil";
/** /api/soru/profil'in döndüğü, öğrenciye okunan profil satırı. */
export type ProfilSatiri = { alan: ProfilAlani; metin: string };
export interface Mesaj { export interface Mesaj {
id: string; id: string;
@@ -123,8 +127,11 @@ export function SohbetClient({
ozet, ozet,
ilkYorum, ilkYorum,
bildikleri, bildikleri,
initialProfil,
}: { }: {
baslangicKredi: number; baslangicKredi: number;
/** Sunucu tarafında çekilmiş danışman profili; verilirse mount fetch'i atlanır */
initialProfil?: ProfilSatiri[];
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */ /** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
initialMesajlar?: Mesaj[]; initialMesajlar?: Mesaj[];
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */ /** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
@@ -146,8 +153,57 @@ export function SohbetClient({
const [kredi, setKredi] = useState(baslangicKredi); const [kredi, setKredi] = useState(baslangicKredi);
const [temizleAcik, setTemizleAcik] = useState(false); const [temizleAcik, setTemizleAcik] = useState(false);
const [temizleniyor, setTemizleniyor] = useState(false); const [temizleniyor, setTemizleniyor] = useState(false);
// Danışmanın sohbetten çıkarıp kaydettiği tercihler (kapalı listeli profil).
// Başlığın altında çip olarak durur; çarpı tek alanı siler. Cevap sonrası
// yeniden çekilir: model profil_guncelle çağırdıysa yeni çip burada belirir.
const [profil, setProfil] = useState<ProfilSatiri[]>(initialProfil ?? []);
const router = useRouter(); const router = useRouter();
async function profiliCek(oncekiAlanlar: Set<ProfilAlani>) {
try {
const res = await fetch("/api/soru/profil");
if (!res.ok) return;
const data = (await res.json()) as { satirlar?: ProfilSatiri[] };
const satirlar = data.satirlar ?? [];
setProfil(satirlar);
for (const s of satirlar) {
// Analitiğe yalnız alan adı gider, değer gitmez (KVKK)
if (!oncekiAlanlar.has(s.alan)) olay("profil_alani_kaydedildi", { alan: s.alan });
}
} catch {}
}
useEffect(() => {
// Listem sayfası profili sunucudan indirir; /sonuc popup'ı gibi geç mount
// olan yerler çeker (ttlMs 0: yalnız in-flight dedup, DELETE sonrası
// bayat kalmasın).
if (initialProfil != null) return;
onbellekliJsonGetir<{ satirlar?: ProfilSatiri[] }>("/api/soru/profil", {
ttlMs: 0,
})
.then((data) => setProfil(data?.satirlar ?? []))
.catch(() => {});
}, [initialProfil]);
async function profilAlaniSil(alan: ProfilAlani) {
const onceki = profil;
setProfil((p) => p.filter((s) => s.alan !== alan));
try {
const res = await fetch("/api/soru/profil", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ alan }),
});
if (!res.ok) throw new Error("silinemedi");
const data = (await res.json()) as { satirlar?: ProfilSatiri[] };
setProfil(data.satirlar ?? []);
olay("profil_alani_silindi", { alan });
} catch {
setProfil(onceki);
toast.error("Silinemedi, tekrar dener misin?");
}
}
// Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı // Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı
// (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın. // (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın.
useEffect(() => { useEffect(() => {
@@ -236,6 +292,8 @@ export function SohbetClient({
// Header'daki kredi pill'i layout'ta server-render edilir ve client // Header'daki kredi pill'i layout'ta server-render edilir ve client
// harcamasını görmez; bakiye orada da güncellensin. // harcamasını görmez; bakiye orada da güncellensin.
router.refresh(); router.refresh();
// Danışman bu cevapta profil_guncelle çağırmış olabilir
void profiliCek(new Set(profil.map((s) => s.alan)));
} catch { } catch {
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir; // Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da // tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
@@ -358,6 +416,35 @@ export function SohbetClient({
</div> </div>
</div> </div>
{profil.length > 0 ? (
<div
role="group"
aria-label="Danışmanın kaydettiği tercihlerin"
className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-2.5"
>
<span className="text-xs font-semibold text-slate-500">
Kaydettiklerim
</span>
{profil.map((s) => (
// Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil.
<span
key={s.alan}
className="inline-flex h-8 items-center gap-1 rounded-full border border-slate-200 bg-white pr-1 pl-3 text-xs font-semibold text-slate-700"
>
{s.metin}
<button
type="button"
onClick={() => profilAlaniSil(s.alan)}
aria-label={`Sil: ${s.metin}`}
className="inline-flex size-6 cursor-pointer items-center justify-center rounded-full text-slate-500 transition-[background-color,color,transform] duration-150 hover:bg-slate-100 hover:text-orange-500 active:scale-[0.96]"
>
<X className="size-3.5" aria-hidden />
</button>
</span>
))}
</div>
) : null}
<Dialog open={temizleAcik} onOpenChange={setTemizleAcik}> <Dialog open={temizleAcik} onOpenChange={setTemizleAcik}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>

456
src/lib/ai/araclar.ts Normal file
View File

@@ -0,0 +1,456 @@
/**
* Danışmanın araçları (OpenAI uyumlu function calling, OpenRouter üzerinden).
*
* İki tür: SORGU araçları salt okunur — model sayı uydurmaz, DB'den okur;
* `profil_guncelle` sohbetten çıkarılan tercihi KAPALI LİSTELİ profile yazar
* (src/lib/danisman-profil.ts). Yapay zekâ listeyi araçla KURMAZ: 24'lük liste
* üretimi deterministik havuzda kalır (havuz-dışına-çıkamama kuralı).
*
* Araç sonuçları kısa ve yapısal döner (prompt bütçesi; sohbet 1024 token).
*/
import type OpenAI from "openai";
import {
dilimAraligi,
EFEKTIF_SIRA,
getDb,
programGetir,
programSatiri,
SELECT_COLS,
type ProgramHamSatiri,
} from "@/lib/db";
import {
kosulMetinleri,
ogretimUyesiToplami,
ozellikOzeti,
programOzellikGetir,
programOzellikleriGetir,
type ProgramOzellik,
} from "@/lib/program-ozellik";
import { getAllUniversiteler, getUniversiteBySlug } from "@/lib/katalog";
import { kadinYuzdesi, ogrenciBasinaUye, YOK_ISTATISTIK_DONEM } from "@/lib/yok-istatistik";
import { URAP_DONEM, URAP_TOPLAM } from "@/lib/urap-siralama";
import { turkishSlugify } from "@/lib/slug";
import { DILIM_ETIKET } from "@/lib/risk";
import { YILLAR } from "@/lib/veri-yillari";
import { PUAN_TURLERI, type Program, type PuanTuruKey } from "@/types/yokatlas";
import {
PROFIL_ALAN_ACIKLAMA,
PROFIL_ALANLARI,
PROFIL_SECENEKLERI,
profilSatirlari,
type DanismanProfili,
type ProfilAlani,
} from "@/lib/danisman-profil";
import { profilAlanYaz, profilGetir } from "@/lib/danisman-profil-db";
import { ilAdiNormalize, illerArasiKm, yakinIller } from "@/lib/mesafe";
export type AracBaglami = {
userId: string;
sira: number;
tur: PuanTuruKey;
/** Sohbet başındaki profil; profil_guncelle sonrası yenilenir */
profil: DanismanProfili;
};
export type AracTanimi = OpenAI.Chat.Completions.ChatCompletionTool;
const ARAMA_LIMIT = 8;
export const ARACLAR: AracTanimi[] = [
{
type: "function",
function: {
name: "program_ara",
description:
"Adayın puan türündeki programları veriden arar (YÖK Atlas, son yerleştirme). Bölüm adı, üniversite, il, öğretim dili, burs ve yıllık ücret üst sınırına göre süzer; adayın sırasına en yakın programları döner. Sayı uydurmak yerine bunu çağır.",
parameters: {
type: "object",
properties: {
arama: { type: "string", description: "Bölüm adı parçası, ör. 'bilgisayar mühendisliği', 'hukuk'" },
universite: { type: "string", description: "Üniversite adı parçası" },
il: { type: "string", description: "İl adı, ör. 'İzmir'" },
dil: { type: "string", enum: ["Türkçe", "İngilizce"], description: "Öğretim dili" },
burs: {
type: "string",
enum: ["Burslu", "%50 İndirimli", "%25 İndirimli", "Ücretli"],
description: "Vakıf burs oranı",
},
ucret_ust: { type: "integer", description: "Yıllık ücret üst sınırı (TL); devlet programları her zaman geçer" },
dilim: {
type: "string",
enum: ["hayal", "dengeli", "guvenli"],
description: "Yalnız bu dilimdekiler: hayal = adayın sırasının üstünde kapananlar, dengeli = sırasının 1,4 katına kadar, guvenli = daha altı",
},
},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "program_detay",
description:
"Tek programın ayrıntısı: yıllara göre taban sırası ve kontenjan, ücret/burs, dil, öğretim türü, akreditasyon, kadro, tercih barajı ve kılavuzdaki yerleşme koşulları (sağlık şartı, mülakat vb.).",
parameters: {
type: "object",
properties: {
program_id: { type: "string", description: "program_ara sonucundaki id (kılavuz kodu)" },
},
required: ["program_id"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "universite_profili",
description:
"Üniversitenin künyesi: il, devlet/vakıf, program sayısı, URAP araştırma sırası, örgün öğrenci sayısı, hoca başına öğrenci ve kadın/erkek öğrenci oranı (üniversite geneli, bölüm bazında veri yok).",
parameters: {
type: "object",
properties: {
universite: { type: "string", description: "Üniversite adı, ör. 'Boğaziçi', 'ODTÜ', 'Ege Üniversitesi'" },
},
required: ["universite"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "profil_guncelle",
description:
"Adayın sohbette söylediği bir tercihi profile kaydeder ya da siler (deger null). YALNIZ şu alanlar: " +
PROFIL_ALANLARI.map((a) => `${a} (${PROFIL_ALAN_ACIKLAMA[a]})`).join("; ") +
". Sağlık, engel, din, aile, gelir, cinsiyet gibi bilgileri ASLA kaydetme. Kaydettikten sonra cevabında tek cümleyle 'Şunu kaydettim: …' de.",
parameters: {
type: "object",
properties: {
alan: { type: "string", enum: PROFIL_ALANLARI },
deger: {
type: ["string", "null"],
description:
"evIl: il adı; maxKm: sayı (km); ucretUst: sayı (TL, yıllık); " +
`burs: ${Object.keys(PROFIL_SECENEKLERI.burs).join("|")}; ` +
`dil: ${Object.keys(PROFIL_SECENEKLERI.dil).join("|")}; ` +
`ogretimTuru: ${Object.keys(PROFIL_SECENEKLERI.ogretimTuru).join("|")}; ` +
`kampus: ${Object.keys(PROFIL_SECENEKLERI.kampus).join("|")}; null = alanı sil`,
},
},
required: ["alan", "deger"],
additionalProperties: false,
},
},
},
];
// ── Yardımcılar ──────────────────────────────────────────────────────────
function dilimAdi(sira: number, efektif: number | null): string {
if (efektif == null) return "Veri yetersiz";
if (efektif < sira) return DILIM_ETIKET.hayal;
if (efektif <= dilimAraligi(sira, "dengeli").ust!) return DILIM_ETIKET.dengeli;
return DILIM_ETIKET.garanti;
}
function programOzeti(p: Program, sira: number, o?: ProgramOzellik) {
return {
id: p.id,
program: p.isim,
universite: p.universite,
il: p.il,
tur: p.unitur,
tabanSira: p.efektifSira,
dilim: dilimAdi(sira, p.efektifSira),
kontenjan: p.kontenjanSon,
ozellik: o ? ozellikOzeti(o) : [],
};
}
/** SQLite LIKE Türkçe büyük/küçük harfi bilmez: hem yazıldığı gibi hem başlık düzeni denenir. */
function likeVaryantlari(q: string): string[] {
const t = q.trim();
const baslik = t
.toLocaleLowerCase("tr-TR")
.split(" ")
.map((w) => (w ? w[0].toLocaleUpperCase("tr-TR") + w.slice(1) : w))
.join(" ");
return Array.from(new Set([t, baslik, t.toLocaleUpperCase("tr-TR")]));
}
function likeKosulu(kolon: string, q: string): { sql: string; args: string[] } {
const v = likeVaryantlari(q);
return {
sql: `(${v.map(() => `${kolon} LIKE ?`).join(" OR ")})`,
args: v.map((x) => `%${x}%`),
};
}
// ── Araç gövdeleri ───────────────────────────────────────────────────────
type ProgramAraArg = {
arama?: string;
universite?: string;
il?: string;
dil?: string;
burs?: string;
ucret_ust?: number;
dilim?: "hayal" | "dengeli" | "guvenli";
};
function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): ProgramAraSonuc {
const db = getDb();
const profil: DanismanProfili = profilUygula ? b.profil : {};
const kosullar: string[] = ["tur = ?", "onlisans = ?", `${EFEKTIF_SIRA} IS NOT NULL`];
const args: (string | number)[] = [PUAN_TURLERI[b.tur], b.tur === "tyt" ? 1 : 0];
const uygulanan: string[] = [];
if (a.arama) {
const k = likeKosulu("isim", a.arama);
kosullar.push(k.sql);
args.push(...k.args);
uygulanan.push(`bölüm ~ "${a.arama}"`);
}
if (a.universite) {
const k = likeKosulu("universite", a.universite);
kosullar.push(k.sql);
args.push(...k.args);
uygulanan.push(`üniversite ~ "${a.universite}"`);
}
// İl: parametre > profildeki km sınırı
if (a.il) {
const il = ilAdiNormalize(a.il);
if (!il) {
return { toplamEslesen: 0, gosterilen: 0, uygulananFiltreler: [], sonuclar: [], not: `İl tanınmadı: ${a.il}` };
}
kosullar.push("il = ?");
args.push(il);
uygulanan.push(`il = ${il}`);
} else if (profil.evIl && profil.maxKm) {
const iller = yakinIller(profil.evIl, profil.maxKm);
if (iller.length) {
kosullar.push(`il IN (${iller.map(() => "?").join(",")})`);
args.push(...iller);
uygulanan.push(`profil: ${profil.evIl} iline en fazla ${profil.maxKm} km (${iller.length} il)`);
}
}
// Özellik süzgeçleri alt sorguda (program_ozellik.id çakışmasın)
const ozKosul: string[] = [];
const ozArgs: (string | number)[] = [];
const dil = a.dil ?? (profil.dil === "ingilizce" ? "İngilizce" : profil.dil === "turkce" ? "Türkçe" : undefined);
if (dil) {
ozKosul.push("ogrenim_dili LIKE ?");
ozArgs.push(`${dil}%`);
uygulanan.push(`dil = ${dil}${a.dil ? "" : " (profil)"}`);
}
if (a.burs) {
ozKosul.push("burs_orani = ?");
ozArgs.push(a.burs);
uygulanan.push(`burs = ${a.burs}`);
} else if (profil.burs === "yalniz-burslu") {
ozKosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
uygulanan.push("profil: vakıfta yalnız burslu");
}
const ucretUst = a.ucret_ust ?? profil.ucretUst;
if (ucretUst) {
ozKosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
ozArgs.push(ucretUst);
uygulanan.push(`yıllık ücret ≤ ${ucretUst.toLocaleString("tr-TR")} TL${a.ucret_ust ? "" : " (profil)"}`);
}
if (profil.ogretimTuru === "yalniz-orgun") {
ozKosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
uygulanan.push("profil: yalnız örgün");
}
if (ozKosul.length) {
kosullar.push(`id IN (SELECT id FROM program_ozellik WHERE ${ozKosul.join(" AND ")})`);
args.push(...ozArgs);
}
if (a.dilim) {
const anahtar = a.dilim === "guvenli" ? "garanti" : a.dilim;
const { alt, ust } = dilimAraligi(b.sira, anahtar);
kosullar.push(ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`);
args.push(alt);
if (ust != null) args.push(ust);
uygulanan.push(`dilim = ${DILIM_ETIKET[anahtar]}`);
}
const satirlar = db
.prepare(
`SELECT ${SELECT_COLS} FROM programs WHERE ${kosullar.join(" AND ")}
ORDER BY ABS(${EFEKTIF_SIRA} - ?) ASC LIMIT ?`,
)
.all(...args, b.sira, ARAMA_LIMIT) as ProgramHamSatiri[];
const programlar = satirlar.map(programSatiri);
const toplam = (
db.prepare(`SELECT COUNT(*) AS n FROM programs WHERE ${kosullar.join(" AND ")}`).get(...args) as {
n: number;
}
).n;
// Profil süzgeçleri sonucu sıfırladıysa profilsiz tekrar dene ve bunu söyle:
// model "hiç yok" demek yerine "profilin dışında şunlar var" diyebilsin.
if (toplam === 0 && profilUygula && uygulanan.some((u) => u.includes("profil"))) {
const gevsek = programAra(b, a, false);
return {
...gevsek,
not: `Profil süzgeçleriyle (${uygulanan.filter((u) => u.includes("profil")).join("; ")}) eşleşen program YOK; aşağıdakiler profil süzgeci olmadan bulundu — adaya bunu açıkça söyle. ${gevsek.not}`,
};
}
const ozellikler = programOzellikleriGetir(programlar.map((p) => p.id));
return {
toplamEslesen: toplam,
gosterilen: programlar.length,
uygulananFiltreler: uygulanan,
sonuclar: programlar.map((p) => programOzeti(p, b.sira, ozellikler[p.id])),
not: "tabanSira son yerleştirme yılının taban başarı sırası; her yıl değişir.",
};
}
type ProgramAraSonuc = {
toplamEslesen: number;
gosterilen: number;
uygulananFiltreler: string[];
sonuclar: ReturnType<typeof programOzeti>[];
not: string;
};
function programDetay(b: AracBaglami, a: { program_id?: string }) {
const id = String(a.program_id ?? "").trim();
const p = id ? programGetir(id) : null;
if (!p) return { hata: "Program bulunamadı; önce program_ara ile id al." };
const o = programOzellikGetir(id);
const kosullar = o ? kosulMetinleri(o.kosulKodlari).slice(0, 8) : [];
const evKm = b.profil.evIl && p.il ? illerArasiKm(b.profil.evIl, p.il) : null;
return {
...programOzeti(p, b.sira, o ?? undefined),
fakulte: p.fakulte,
sure: p.sure,
tabanSiraYillara: Object.fromEntries(YILLAR.map((y, i) => [y, p.siraGecmisi[i]])),
puanSon: p.puanSon,
yerlesenSon: p.yerlesenSon,
ucretYillik: o?.ucret ?? null,
bursOrani: o?.bursOrani ?? null,
ogretimDili: o?.ogrenimDili ?? null,
ogretimTuru: o?.ogrenimTuru ?? null,
akreditasyon: o?.akreditasyon ?? null,
kadro: o
? { prof: o.prof, docent: o.doc, drOgrUyesi: o.dou, arsGor: o.argor, ogretimUyesiToplam: ogretimUyesiToplami(o) }
: null,
tercihBaraji: o?.minBasariSirasi ?? null,
ilce: o?.ilce ?? null,
evdenKm: evKm,
kosullar: kosullar.map((k) => (k.metin.length > 240 ? `${k.metin.slice(0, 237)}…` : k.metin)),
};
}
function universiteProfili(_b: AracBaglami, a: { universite?: string }) {
const q = String(a.universite ?? "").trim();
if (!q) return { hata: "Üniversite adı gerekli." };
const slugQ = turkishSlugify(q);
const hepsi = getAllUniversiteler();
const kisaltma: Record<string, string> = {
odtu: "orta-dogu-teknik",
itu: "istanbul-teknik",
ytu: "yildiz-teknik",
ktu: "karadeniz-teknik",
dtu: "deu",
};
const arananSlug = kisaltma[slugQ] ?? slugQ;
const aday =
hepsi.find((u) => u.slug === arananSlug || u.slug === `${arananSlug}-universitesi`) ??
hepsi.find((u) => u.slug.startsWith(arananSlug)) ??
hepsi.find((u) => u.slug.includes(arananSlug));
if (!aday) return { hata: `Üniversite bulunamadı: ${q}` };
const u = getUniversiteBySlug(aday.slug);
if (!u) return { hata: `Üniversite bulunamadı: ${q}` };
const i = u.istatistik;
const kadin = i ? kadinYuzdesi(i) : null;
return {
ad: u.ad,
il: u.il,
tur: u.unitur,
programSayisi: u.programSayisi,
enDusukTabanSira: u.stats.minSira,
toplamKontenjan: u.stats.toplamKontenjan,
urap: u.urap ? { sira: u.urap.sira, toplam: URAP_TOPLAM, donem: URAP_DONEM, olctugu: "araştırma performansı; eğitim kalitesi ya da iş bulma değil" } : null,
istatistik: i
? {
donem: YOK_ISTATISTIK_DONEM,
orgunOgrenci: i.ogrenci,
ogretimUyesi: i.ogretimUyesi,
hocaBasinaOgrenci: ogrenciBasinaUye(i),
kadinYuzdesi: kadin,
erkekYuzdesi: kadin == null ? null : 100 - kadin,
not: "Kadın/erkek oranı örgün ön lisans + lisans, ÜNİVERSİTE GENELİ; bölüm bazında veri yok. Bilgi olarak söyle, filtre yapma.",
}
: null,
sayfa: `/universite/${u.slug}`,
};
}
function sayiParse(v: unknown): number | null {
if (typeof v === "number") return v;
if (typeof v !== "string") return null;
const t = v.toLocaleLowerCase("tr-TR").replace(/\s/g, "");
const m = t.match(/^([\d.,]+)(bin|k|milyon|m)?/);
if (!m) return null;
let n = Number.parseFloat(m[1].replace(/\./g, "").replace(",", "."));
if (!Number.isFinite(n)) return null;
if (m[2] === "bin" || m[2] === "k") n *= 1000;
if (m[2] === "milyon" || m[2] === "m") n *= 1_000_000;
return Math.round(n);
}
async function profilGuncelle(b: AracBaglami, a: { alan?: string; deger?: unknown }) {
const alan = a.alan as ProfilAlani;
if (!PROFIL_ALANLARI.includes(alan)) {
return { hata: `Bilinmeyen alan: ${String(a.alan)}. Yalnız ${PROFIL_ALANLARI.join(", ")} kaydedilebilir.` };
}
let deger: unknown = a.deger;
if (deger !== null && deger !== undefined) {
if (alan === "maxKm" || alan === "ucretUst") {
deger = sayiParse(deger);
if (deger == null) return { hata: "Sayı okunamadı." };
} else if (alan === "evIl") {
const il = ilAdiNormalize(String(deger));
if (!il) return { hata: `İl tanınmadı: ${String(deger)}` };
deger = il;
} else {
deger = String(deger).trim();
}
}
try {
const profil = await profilAlanYaz(b.userId, alan, deger ?? null);
b.profil = profil;
const satirlar = profilSatirlari(profil);
return {
kaydedildi: deger == null ? `${alan} silindi` : satirlar.find((s) => s.alan === alan)?.metin,
profil: satirlar.map((s) => s.metin),
};
} catch {
return { hata: `Değer kabul edilmedi (${alan}); aralık dışı ya da geçersiz seçenek.` };
}
}
/** Route'un araç döngüsüne verdiği çalıştırıcı. Bilinmeyen araç hata döner. */
export function aracCalistirici(b: AracBaglami) {
return async (ad: string, arg: unknown): Promise<unknown> => {
const a = (arg && typeof arg === "object" ? arg : {}) as Record<string, unknown>;
switch (ad) {
case "program_ara":
return programAra(b, a as ProgramAraArg);
case "program_detay":
return programDetay(b, a);
case "universite_profili":
return universiteProfili(b, a);
case "profil_guncelle":
return profilGuncelle(b, a);
default:
return { hata: `Bilinmeyen araç: ${ad}` };
}
};
}
export { profilGetir };

View File

@@ -1,3 +1,4 @@
import type OpenAI from "openai";
import { z } from "zod"; import { z } from "zod";
import { getOpenRouter, raporModeli, sohbetModeli } from "./client"; import { getOpenRouter, raporModeli, sohbetModeli } from "./client";
@@ -166,3 +167,109 @@ export async function* sohbetAkisi(opts: {
// sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat. // sanılmaması ve iade yolunun çalışması için zaman aşımını elle fırlat.
if (sinyal.aborted) throw sinyal.reason; if (sinyal.aborted) throw sinyal.reason;
} }
// ── Araçlı sohbet ─────────────────────────────────────────────────────────
type AracCagrisiBirikim = { id: string; name: string; arguments: string };
const ARAC_TUR_SINIRI = 4;
/**
* Araç çağrılı sohbet akışı. Model araç isterse (stream'de tool_calls
* parçaları birikir) araçlar çalıştırılır, sonuçlar `tool` mesajı olarak
* eklenir ve akış yeniden başlar; en fazla ARAC_TUR_SINIRI tur, son turda
* araç kapalı (cevap vermek zorunda). Metin parçaları geldikçe yield edilir.
*
* Sağlayıcı araçları desteklemiyorsa (ilk çağrıda 400/404) düz sohbete düşer.
*/
export async function* sohbetAkisiAraclarla(opts: {
system: string;
mesajlar: SohbetMesaji[];
maxTokens: number;
araclar: OpenAI.Chat.Completions.ChatCompletionTool[];
calistir: (ad: string, argumanlar: unknown) => Promise<unknown>;
/** Her araç çağrısında (günlük/analitik için) */
onArac?: (ad: string) => void;
}): AsyncGenerator<string> {
const sinyal = AbortSignal.timeout(120_000);
const gecmis: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "system", content: opts.system },
...opts.mesajlar,
];
for (let tur = 0; tur < ARAC_TUR_SINIRI; tur++) {
const sonTur = tur === ARAC_TUR_SINIRI - 1;
let akis;
try {
akis = await geciciHatadaTekrarla(() =>
getOpenRouter().chat.completions.create(
{
model: sohbetModeli(),
max_tokens: opts.maxTokens,
reasoning_effort: "none",
stream: true,
messages: gecmis,
tools: opts.araclar,
tool_choice: sonTur ? "none" : "auto",
},
{ signal: sinyal },
),
);
} catch (hata) {
const kod = (hata as { status?: number })?.status;
if (tur === 0 && (kod === 400 || kod === 404)) {
console.warn("[cagri] sağlayıcı araç çağrısını kabul etmedi, düz sohbete düşüldü:", kod);
yield* sohbetAkisi({ system: opts.system, mesajlar: opts.mesajlar, maxTokens: opts.maxTokens });
return;
}
throw hata;
}
let metin = "";
const cagrilar = new Map<number, AracCagrisiBirikim>();
for await (const parca of akis) {
const delta = parca.choices?.[0]?.delta;
if (!delta) continue;
if (delta.content) {
metin += delta.content;
yield delta.content;
}
for (const tc of delta.tool_calls ?? []) {
const mevcut = cagrilar.get(tc.index) ?? { id: "", name: "", arguments: "" };
if (tc.id) mevcut.id = tc.id;
if (tc.function?.name) mevcut.name += tc.function.name;
if (tc.function?.arguments) mevcut.arguments += tc.function.arguments;
cagrilar.set(tc.index, mevcut);
}
}
if (sinyal.aborted) throw sinyal.reason;
if (cagrilar.size === 0) return;
const liste = [...cagrilar.values()].filter((c) => c.name);
const toolCalls = liste.map((c) => ({
id: c.id || `call_${Math.random().toString(36).slice(2, 10)}`,
type: "function" as const,
function: { name: c.name, arguments: c.arguments || "{}" },
}));
gecmis.push({ role: "assistant", content: metin || null, tool_calls: toolCalls });
for (const [i, c] of liste.entries()) {
opts.onArac?.(c.name);
let arg: unknown = {};
try {
arg = c.arguments ? JSON.parse(c.arguments) : {};
} catch {
arg = {};
}
let sonuc: unknown;
try {
sonuc = await opts.calistir(c.name, arg);
} catch (hata) {
console.error("[cagri] araç hatası:", c.name, hata);
sonuc = { hata: "Araç çalışmadı; veriye ulaşılamadı, uydurma." };
}
gecmis.push({ role: "tool", tool_call_id: toolCalls[i].id, content: JSON.stringify(sonuc) });
}
// Araç turundan sonra araya boşluk: metin + tool ile bitmiş cümle yapışmasın
if (metin && !metin.endsWith("\n")) yield "\n\n";
}
}

View File

@@ -50,7 +50,12 @@ export type OlayAdi =
// kapi_kapatildi {adim, tetik}: yalnız TAMAMLANMADAN kapanış (X, dışarı // kapi_kapatildi {adim, tetik}: yalnız TAMAMLANMADAN kapanış (X, dışarı
// tıklama, Vazgeç); tamamlanan kapıda atılmaz. // tıklama, Vazgeç); tamamlanan kapıda atılmaz.
| "kapi_acildi" | "kapi_acildi"
| "kapi_kapatildi"; | "kapi_kapatildi"
// Danışman profili: yalnız ALAN ADI gider, değer gitmez (KVKK: analitiğe
// profil değeri sızmaz). kaydedildi = model aracı çağırıp alanı yazdı
// (istemci cevap sonrası profili çekip farkı görür); silindi = çipteki çarpı.
| "profil_alani_kaydedildi"
| "profil_alani_silindi";
export type Ozellikler = Record<string, string | number>; export type Ozellikler = Record<string, string | number>;

View File

@@ -231,3 +231,15 @@ export const savedLists = sqliteTable("saved_lists", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull(), createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
}); });
// Danışman profili: sohbetten `profil_guncelle` aracıyla çıkarılan, KAPALI
// LİSTELİ tercih alanları (bkz. src/lib/danisman-profil.ts). Serbest metin
// yok; öğrenci /listem'de görür ve alan alan siler. Kullanıcı başına tek satır.
export const danismanProfil = sqliteTable("danisman_profil", {
userId: text("user_id")
.primaryKey()
.references(() => user.id),
// DanismanProfili (yalnız tanımlı anahtarlar; okurken yeniden doğrulanır)
alanlar: text("alanlar", { mode: "json" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

View File

@@ -0,0 +1,56 @@
import "server-only";
import { eq } from "drizzle-orm";
import { appDb, schema } from "@/lib/appdb";
import {
PROFIL_SEMASI,
profilTemizle,
type DanismanProfili,
type ProfilAlani,
} from "@/lib/danisman-profil";
/** Kullanıcının danışman profili; satır yoksa boş nesne. Bozuk alanlar düşer. */
export async function profilGetir(userId: string): Promise<DanismanProfili> {
const satir = await appDb.query.danismanProfil.findFirst({
where: eq(schema.danismanProfil.userId, userId),
columns: { alanlar: true },
});
return profilTemizle(satir?.alanlar);
}
/**
* Tek alanı yazar (deger null → alan silinir). Değer şemadan geçmezse hata
* fırlatır; çağıran taraf (araç) bunu modele "kaydedilmedi" diye döner.
*/
export async function profilAlanYaz(
userId: string,
alan: ProfilAlani,
deger: unknown,
): Promise<DanismanProfili> {
const mevcut = await profilGetir(userId);
const yeni: Record<string, unknown> = { ...mevcut };
if (deger === null || deger === undefined) {
delete yeni[alan];
} else {
const dogrulama = PROFIL_SEMASI.shape[alan].safeParse(deger);
if (!dogrulama.success) {
throw new Error(`Geçersiz değer: ${alan}`);
}
yeni[alan] = dogrulama.data;
}
const alanlar = PROFIL_SEMASI.parse(yeni);
const now = new Date();
await appDb
.insert(schema.danismanProfil)
.values({ userId, alanlar, updatedAt: now })
.onConflictDoUpdate({
target: schema.danismanProfil.userId,
set: { alanlar, updatedAt: now },
});
return alanlar;
}
export async function profilSil(userId: string): Promise<void> {
await appDb
.delete(schema.danismanProfil)
.where(eq(schema.danismanProfil.userId, userId));
}

114
src/lib/danisman-profil.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Danışman profili — sohbetten çıkarılan öğrenci tercihleri. KAPALI LİSTE:
* yalnız burada tanımlı alanlar yazılabilir, serbest metin alanı yoktur.
* Neden: model sohbetten din, sağlık, aile gibi özel nitelikli veriyi profile
* taşıyamasın (KVKK md. 6); öğrenci her alanı görür ve tek tıkla siler
* (docs/arastirma/2026-09-25-kisisellestirme/README.md §8).
*
* Sorulmayanlar: cinsiyet, sağlık/engel, gelir. Ücret üst sınırı sorulur, gelir
* sorulmaz. Konum il düzeyindedir (adres/koordinat değil).
*
* Saf modül: client, server ve scripts aynı dosyayı okur.
*/
import { z } from "zod";
export const PROFIL_SECENEKLERI = {
burs: {
"yalniz-burslu": "yalnız burslu",
yuzde50: "%50 burslu da olur",
"ucretli-olur": "ücretli de olur",
},
dil: {
turkce: "Türkçe",
ingilizce: "İngilizce",
farketmez: "fark etmez",
},
ogretimTuru: {
"yalniz-orgun": "yalnız örgün",
"uzaktan-olur": "uzaktan/açık da olur",
},
kampus: {
sakin: "sakin, küçük",
kalabalik: "kalabalık, büyük",
farketmez: "fark etmez",
},
} as const;
export const PROFIL_SEMASI = z
.object({
/** Yaşadığı il — DB'deki il adı biçimi (büyük harf, Türkçe). */
evIl: z.string().min(2).max(40).optional(),
/** Evden en fazla kaç km (il merkezleri arası kuş uçuşu). */
maxKm: z.number().int().min(50).max(1500).optional(),
/** Vakıfta yıllık ücret üst sınırı (TL). */
ucretUst: z.number().int().min(50_000).max(5_000_000).optional(),
burs: z.enum(["yalniz-burslu", "yuzde50", "ucretli-olur"]).optional(),
dil: z.enum(["turkce", "ingilizce", "farketmez"]).optional(),
ogretimTuru: z.enum(["yalniz-orgun", "uzaktan-olur"]).optional(),
kampus: z.enum(["sakin", "kalabalik", "farketmez"]).optional(),
})
.strict();
export type DanismanProfili = z.infer<typeof PROFIL_SEMASI>;
export type ProfilAlani = keyof DanismanProfili;
export const PROFIL_ALANLARI = Object.keys(PROFIL_SEMASI.shape) as ProfilAlani[];
/** Alanın öğrenciye görünen kısa açıklaması (çip ve araç şeması aynı metni kullanır). */
export const PROFIL_ALAN_ACIKLAMA: Record<ProfilAlani, string> = {
evIl: "Yaşadığı il (evden uzaklık için)",
maxKm: "Evden en fazla kaç km uzakta okumak istediği",
ucretUst: "Vakıfta yıllık ücret üst sınırı (TL)",
burs: "Vakıfta burs beklentisi",
dil: "Öğretim dili tercihi",
ogretimTuru: "Uzaktan/açık öğretim kabul mü",
kampus: "Kampüs büyüklüğü tercihi",
};
/** Bilinmeyen anahtarları atarak profili güvenli okur; bozuk değer alanı düşürür. */
export function profilTemizle(ham: unknown): DanismanProfili {
if (!ham || typeof ham !== "object") return {};
const sonuc: Record<string, unknown> = {};
for (const alan of PROFIL_ALANLARI) {
const deger = (ham as Record<string, unknown>)[alan];
if (deger === undefined || deger === null) continue;
const tek = PROFIL_SEMASI.shape[alan].safeParse(deger);
if (tek.success) sonuc[alan] = tek.data;
}
return sonuc as DanismanProfili;
}
/** Tek alanı öğrencinin okuyacağı cümleye çevirir: "Yıllık ücret en fazla 300.000 TL". */
export function profilAlaniMetni(alan: ProfilAlani, deger: unknown): string {
switch (alan) {
case "evIl":
return `Yaşadığı il: ${ilBaslik(String(deger))}`;
case "maxKm":
return `Evden en fazla ${Number(deger).toLocaleString("tr-TR")} km`;
case "ucretUst":
return `Yıllık ücret en fazla ${Number(deger).toLocaleString("tr-TR")} TL`;
case "burs":
return `Vakıfta ${PROFIL_SECENEKLERI.burs[deger as keyof typeof PROFIL_SECENEKLERI.burs]}`;
case "dil":
return `Öğretim dili: ${PROFIL_SECENEKLERI.dil[deger as keyof typeof PROFIL_SECENEKLERI.dil]}`;
case "ogretimTuru":
return deger === "yalniz-orgun" ? "Yalnız örgün öğretim" : "Uzaktan/açık öğretim de olur";
case "kampus":
return `Kampüs: ${PROFIL_SECENEKLERI.kampus[deger as keyof typeof PROFIL_SECENEKLERI.kampus]}`;
}
}
/** Profilin tamamı, dolu alanlar sırayla. */
export function profilSatirlari(p: DanismanProfili): { alan: ProfilAlani; metin: string }[] {
return PROFIL_ALANLARI.flatMap((alan) =>
p[alan] === undefined ? [] : [{ alan, metin: profilAlaniMetni(alan, p[alan]) }]
);
}
function ilBaslik(il: string): string {
return il
.toLocaleLowerCase("tr-TR")
.split(" ")
.map((k) => k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1))
.join(" ");
}

42
src/lib/mesafe.ts Normal file
View File

@@ -0,0 +1,42 @@
// İl merkezleri arası kuş uçuşu mesafe (km). Profildeki "evden en fazla N km"
// filtresi il düzeyinde çalışır: adres/koordinat tutulmaz (KVKK, §8).
import { IL_MERKEZ, type LatLng } from "./uni-koordinatlar";
const DUNYA_YARICAP_KM = 6371;
export function kmMesafe(a: LatLng, b: LatLng): number {
const rad = (d: number) => (d * Math.PI) / 180;
const dLat = rad(b[0] - a[0]);
const dLng = rad(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(rad(a[0])) * Math.cos(rad(b[0])) * Math.sin(dLng / 2) ** 2;
return Math.round(2 * DUNYA_YARICAP_KM * Math.asin(Math.sqrt(h)));
}
/** DB il adı biçimine (büyük harf, Türkçe) çevirir; bilinmiyorsa null. */
export function ilAdiNormalize(ham: string): string | null {
const aday = ham.trim().toLocaleUpperCase("tr-TR");
if (aday in IL_MERKEZ) return aday;
// "Afyon" → AFYONKARAHİSAR gibi öneki eşleşen tek il varsa onu al
const adaylar = Object.keys(IL_MERKEZ).filter((il) => il.startsWith(aday));
return adaylar.length === 1 ? adaylar[0] : null;
}
/** Ev iline en fazla `km` uzaktaki iller (evin kendisi dahil), yakından uzağa. */
export function yakinIller(evIl: string, km: number): string[] {
const ev = IL_MERKEZ[evIl];
if (!ev) return [];
return Object.entries(IL_MERKEZ)
.map(([il, k]) => ({ il, km: kmMesafe(ev, k) }))
.filter((x) => x.km <= km)
.sort((a, b) => a.km - b.km)
.map((x) => x.il);
}
/** İki il arası km; biri bilinmiyorsa null. */
export function illerArasiKm(a: string, b: string): number | null {
const ka = IL_MERKEZ[a];
const kb = IL_MERKEZ[b];
return ka && kb ? kmMesafe(ka, kb) : null;
}