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:
6
drizzle/0005_uneven_omega_red.sql
Normal file
6
drizzle/0005_uneven_omega_red.sql
Normal 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
|
||||||
|
);
|
||||||
1011
drizzle/meta/0005_snapshot.json
Normal file
1011
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
29
src/app/api/soru/profil/route.ts
Normal file
29
src/app/api/soru/profil/route.ts
Normal 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) });
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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))}
|
||||||
|
|||||||
@@ -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
456
src/lib/ai/araclar.ts
Normal 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 };
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
56
src/lib/danisman-profil-db.ts
Normal file
56
src/lib/danisman-profil-db.ts
Normal 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
114
src/lib/danisman-profil.ts
Normal 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
42
src/lib/mesafe.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user