diff --git a/src/lib/ai/araclar.ts b/src/lib/ai/araclar.ts index 94ff573..032a837 100644 --- a/src/lib/ai/araclar.ts +++ b/src/lib/ai/araclar.ts @@ -90,7 +90,7 @@ export const ARACLAR: AracTanimi[] = [ universite: { type: "string", description: "Üniversite adı parçası" }, il: { type: "string", description: "İl adı, ör. 'İzmir'" }, tur: { type: "string", enum: ["devlet", "vakif"], description: "Yalnız devlet ya da yalnız vakıf üniversiteleri" }, - dil: { type: "string", enum: ["Türkçe", "İngilizce"], description: "Öğretim dili" }, + dil: { type: "string", enum: ["Türkçe", "İngilizce", "Almanca", "Fransızca", "Arapça", "Rusça"], description: "Öğretim dili" }, ogretim_turu: { type: "string", enum: ["Örgün Öğretim", "Uzaktan Öğretim", "Açık Öğretim"], @@ -318,23 +318,38 @@ function programOzeti(p: Program, sira: number, o?: ProgramOzellik) { }; } -/** 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")])); +/** Arama metninin katlanmış hâli en az bu kadar harf olmalı; daha kısası her kayıtla eşleşir. */ +const ARAMA_ALT_HARF = 3; + +/** Yaygın üniversite kısaltmaları → adın katlanmış (slug) parçası. */ +const UNI_KISALTMA: Record = { + odtu: "orta-dogu-teknik", + itu: "istanbul-teknik", + ytu: "yildiz-teknik", + ktu: "karadeniz-teknik", + deu: "dokuz-eylul", +}; + +const ayrikDegerler = new Map(); + +/** + * Kolonun, katlanmış hâli `katliSorgu`yu içeren değerleri. SQLite LIKE Türkçe + * harf katlamayı bilmez ("isletme" İşletme'yi, "bogazici" Boğaziçi'ni bulmaz); + * ayrık değerler (birkaç bin) bir kez okunur, eşleşme JS'te yapılır. + */ +function katliEslesenler(kolon: "isim" | "universite", katliSorgu: string): string[] { + let liste = ayrikDegerler.get(kolon); + if (!liste) { + liste = (getDb().prepare(`SELECT DISTINCT ${kolon} AS v FROM programs`).all() as { v: string | null }[]) + .filter((x): x is { v: string } => x.v != null) + .map((x) => ({ ham: x.v, katli: turkishSlugify(x.v) })); + ayrikDegerler.set(kolon, liste); + } + return liste.filter((x) => x.katli.includes(katliSorgu)).map((x) => x.ham); } -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}%`), - }; +function inKosulu(kolon: string, degerler: string[]): string { + return degerler.length ? `${kolon} IN (${degerler.map(() => "?").join(",")})` : "0 = 1"; } // ── Araç gövdeleri ─────────────────────────────────────────────────────── @@ -363,24 +378,28 @@ function programAra(b: AracBaglami, a: ProgramAraArg, profilUygula = true): Prog const args: (string | number)[] = [PUAN_TURLERI[b.tur], b.tur === "tyt" ? 1 : 0]; const uygulanan: string[] = []; + const bos = (not: string): ProgramAraSonuc => ({ toplamEslesen: 0, gosterilen: 0, uygulananFiltreler: [], sonuclar: [], not }); if (a.arama) { - const k = likeKosulu("isim", a.arama); - kosullar.push(k.sql); - args.push(...k.args); + const katli = turkishSlugify(a.arama); + if (katli.length < ARAMA_ALT_HARF) return bos(`Bölüm araması için en az ${ARAMA_ALT_HARF} harf yaz: ${a.arama}`); + const adlar = katliEslesenler("isim", katli); + kosullar.push(inKosulu("isim", adlar)); + args.push(...adlar); uygulanan.push(`bölüm ~ "${a.arama}"`); } if (a.universite) { - const k = likeKosulu("universite", a.universite); - kosullar.push(k.sql); - args.push(...k.args); + const slug = turkishSlugify(a.universite); + const katli = UNI_KISALTMA[slug] ?? slug; + if (katli.length < ARAMA_ALT_HARF) return bos(`Üniversite araması için en az ${ARAMA_ALT_HARF} harf yaz: ${a.universite}`); + const adlar = katliEslesenler("universite", katli); + kosullar.push(inKosulu("universite", adlar)); + args.push(...adlar); 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}` }; - } + if (!il) return bos(`İl tanınmadı: ${a.il}`); kosullar.push("il = ?"); args.push(il); uygulanan.push(`il = ${il}`); @@ -594,6 +613,7 @@ function bolumIstihdam(b: AracBaglami, a: { bolumler?: unknown }) { ulkeOrtalamasi: ISTIHDAM_ORTALAMA[duzey], bolumler: adlar.map((ad) => { const slug = bolumSayfaSlug(ad); + if (slug.length < ARAMA_ALT_HARF) return { aranan: ad, hata: `Bölüm adı çok kısa; en az ${ARAMA_ALT_HARF} harf yaz.` }; // Birebir > başı tutan > içinde geçen ("bilgisayar müh" → bilgisayar-muhendisligi) const anahtar = (slug in tablo ? slug : undefined) ?? @@ -619,6 +639,8 @@ function bolumOzeti(_b: AracBaglami, a: { bolum?: string }) { const q = String(a.bolum ?? "").trim(); if (!q) return { hata: "Bölüm adı gerekli." }; const slug = bolumSayfaSlug(q); + // Boş/tek harfli slug her bölümle "başlıyor" sayılır ve ilk kayıt (Abaza Dili) dönerdi + if (slug.length < ARAMA_ALT_HARF) return { hata: `Bölüm adı çok kısa; en az ${ARAMA_ALT_HARF} harf yaz: ${q}` }; const hepsi = getAllBolumler(); const aday = hepsi.find((x) => x.slug === slug) ?? @@ -645,7 +667,8 @@ const IL_UNI_UST = 30; function ilOzeti(_b: AracBaglami, a: { il?: string }) { const q = String(a.il ?? "").trim(); if (!q) return { hata: "İl adı gerekli." }; - const il = getIlBySlug(turkishSlugify(q)); + // "Afyon" → AFYONKARAHİSAR: program_ara ile aynı il eşleşmesi + const il = getIlBySlug(turkishSlugify(ilAdiNormalize(q) ?? q)); if (!il) return { hata: `İl bulunamadı: ${q}` }; return { il: il.ad, @@ -724,14 +747,8 @@ function universiteProfili(_b: AracBaglami, a: { universite?: string }) { if (!q) return { hata: "Üniversite adı gerekli." }; const slugQ = turkishSlugify(q); const hepsi = getAllUniversiteler(); - const kisaltma: Record = { - odtu: "orta-dogu-teknik", - itu: "istanbul-teknik", - ytu: "yildiz-teknik", - ktu: "karadeniz-teknik", - dtu: "deu", - }; - const arananSlug = kisaltma[slugQ] ?? slugQ; + const arananSlug = UNI_KISALTMA[slugQ] ?? slugQ; + if (arananSlug.length < ARAMA_ALT_HARF) return { hata: `Üniversite adı çok kısa; en az ${ARAMA_ALT_HARF} harf yaz: ${q}` }; const aday = hepsi.find((u) => u.slug === arananSlug || u.slug === `${arananSlug}-universitesi`) ?? hepsi.find((u) => u.slug.startsWith(arananSlug)) ?? diff --git a/src/lib/ai/cagri.ts b/src/lib/ai/cagri.ts index f7a7c62..b1a3682 100644 --- a/src/lib/ai/cagri.ts +++ b/src/lib/ai/cagri.ts @@ -1,4 +1,3 @@ -import type OpenAI from "openai"; import { z } from "zod"; import { getOpenRouter, raporModeli } from "./client"; diff --git a/src/lib/ai/sohbet-akisi.ts b/src/lib/ai/sohbet-akisi.ts index 3e3ed00..719111c 100644 --- a/src/lib/ai/sohbet-akisi.ts +++ b/src/lib/ai/sohbet-akisi.ts @@ -117,7 +117,8 @@ export function danismanAkisi(opts: { writer.write({ type: "finish" }); } catch (err) { if (!tamamlandi) await opts.onYarim(metin).catch(() => {}); - console.error("[soru] stream hatası:", err); + // Tam hata nesnesi istek gövdesini ve cevap başlıklarını taşır; yalnız mesaj + console.error("[soru] stream hatası:", err instanceof Error ? err.message : String(err)); // Model hatası akışa zaten "error" parçası olarak yazıldı; yazılmadıysa // (boş cevap, kayıt hatası) istemci hatayı buradan görür. if (akisHatasi == null) throw err; diff --git a/src/lib/mesafe.ts b/src/lib/mesafe.ts index faf9620..017da83 100644 --- a/src/lib/mesafe.ts +++ b/src/lib/mesafe.ts @@ -1,5 +1,6 @@ // İ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 { turkishSlugify } from "./slug"; import { IL_MERKEZ, type LatLng } from "./uni-koordinatlar"; const DUNYA_YARICAP_KM = 6371; @@ -14,13 +15,19 @@ export function kmMesafe(a: LatLng, b: LatLng): number { 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. */ +/** + * DB il adı biçimine (büyük harf, Türkçe) çevirir; bilinmiyorsa null. + * Türkçe harfler katlanarak karşılaştırılır: "IZMIR", "izmir", "İzmir" → İZMİR. + */ export function ilAdiNormalize(ham: string): string | null { - const aday = ham.trim().toLocaleUpperCase("tr-TR"); - if (aday in IL_MERKEZ) return aday; + const aday = turkishSlugify(ham); + if (!aday) return null; + const iller = Object.keys(IL_MERKEZ).map((il) => ({ il, katli: turkishSlugify(il) })); + const tam = iller.find((x) => x.katli === aday); + if (tam) return tam.il; // "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; + const adaylar = iller.filter((x) => x.katli.startsWith(aday)); + return adaylar.length === 1 ? adaylar[0].il : null; } /** Ev iline en fazla `km` uzaktaki iller (evin kendisi dahil), yakından uzağa. */