feat(liste): danışman profili havuzu süzer (önce profil gevşetilir) + KVKK metin düzeltmeleri
- db.ts OzellikSuzgeci (dil, yalnız örgün, yalnız burslu, ücret üst sınırı) program_ozellik alt sorgusuyla; tablo yoksa süzgeç yok. - rapor-havuzu: profilSuzgeci (ev ili + km → il listesi; sihirbazda il seçiliyse km uygulanmaz); havuz 24'ün altına düşerse gevşetme sırası profil → il → kategori. rapor.ts prompt'a 'danışmana söylediği tercihler' ve profil gevşetme notu; rapor-actions profili okuyup geçirir. - /meraklisina şeması (huni-semasi SEMA + aria-label + figcaption) ve 4. bölüm metni yeni adım ve gevşetme sırasıyla eşitlendi (AGENTS.md kuralı). - /api/soru: profil kaydı öncesi 'kaydedeyim mi?' onayı (PROFIL_ONAY_SOR). - Gizlilik metni kodla eşitlendi: OpenRouter→DeepSeek (Anthropic değil), profil satırı, amaç+hukuki sebep, saklama süreleri, md. 11 hakları, 18 yaş altı notu; unvan/adres ve aktarım dayanağı TODO. Giriş kartı: koşullar 'kabul', gizlilik 'okudum'. Sohbet girişine kişisel bilgi uyarısı. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { profilSatirlari, type DanismanProfili } from "@/lib/danisman-profil";
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
@@ -231,8 +232,10 @@ export async function raporUret(
|
||||
params: RaporParams,
|
||||
revizyonNotu?: string,
|
||||
oncekiListeOzeti?: string,
|
||||
profil?: DanismanProfili,
|
||||
): Promise<RaporSonuc> {
|
||||
const { havuz, gevsetildi } = havuzOlustur(params);
|
||||
const { havuz, gevsetildi } = havuzOlustur(params, profil);
|
||||
const profilSatir = profil ? profilSatirlari(profil) : [];
|
||||
if (havuz.length < MIN_LISTE) {
|
||||
throw new RaporUretimHatasi(
|
||||
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
|
||||
@@ -252,6 +255,11 @@ export async function raporUret(
|
||||
(params.universiteTipi && params.universiteTipi !== "farketmez");
|
||||
|
||||
const gevsetmeNotu: string[] = [];
|
||||
if (gevsetildi.includes("profil")) {
|
||||
gevsetmeNotu.push(
|
||||
"Adayın danışmana söylediği tercihlerle (aşağıda) yeterli program yoktu; havuz bu tercihler uygulanmadan kuruldu. Uyanları öne al ve uyarılarda bunu tek cümleyle söyle.",
|
||||
);
|
||||
}
|
||||
if (gevsetildi.includes("il")) {
|
||||
gevsetmeNotu.push(
|
||||
`Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi ${toplamHedef}'e tamamlamak için diğerlerini de kullan.`,
|
||||
@@ -280,6 +288,9 @@ export async function raporUret(
|
||||
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
|
||||
: null,
|
||||
params.notlar ? `Adayın ek notları: ${params.notlar}` : null,
|
||||
profilSatir.length > 0
|
||||
? `ADAYIN DANIŞMANA SÖYLEDİĞİ TERCİHLER (havuz buna göre süzüldü; gerekçelerde uyanı belirt):\n${profilSatir.map((s) => `- ${s.metin}`).join("\n")}`
|
||||
: null,
|
||||
gevsetmeNotu.length > 0 ? gevsetmeNotu.join("\n") : null,
|
||||
revizyonNotu
|
||||
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${revizyonNotu}\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
* Sorulmayanlar: cinsiyet, sağlık/engel, gelir. Ücret üst sınırı sorulur, gelir
|
||||
* sorulmaz. Konum il düzeyindedir (adres/koordinat değil).
|
||||
*
|
||||
* KULLANIM SINIRI (karar, 25 Eyl 2026): profil yalnız liste üretimi
|
||||
* (rapor-havuzu) ve danışman aramalarında kullanılır; pazarlama, e-posta,
|
||||
* segmentleme ya da analitik değerinde KULLANILMAZ — kullanılırsa açık rıza
|
||||
* konusu olur (hukuk-ve-rakipler.md C.3/13). Profil boşken hizmet tam çalışır.
|
||||
*
|
||||
* Saf modül: client, server ve scripts aynı dosyayı okur.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -75,6 +75,48 @@ export function programSatiri(row: ProgramHamSatiri): Program {
|
||||
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
|
||||
* demektir (hayal), büyükse daha güvenli demektir (garanti).
|
||||
*/
|
||||
/**
|
||||
* Danışman profilinden gelen program özelliği süzgeci (program_ozellik
|
||||
* tablosu; bkz. lib/program-ozellik.ts). Tablo yoksa süzgeç uygulanmaz.
|
||||
* Devlet programlarında ücret/burs NULL'dur ve her zaman geçer.
|
||||
*/
|
||||
export type OzellikSuzgeci = {
|
||||
dil?: "Türkçe" | "İngilizce";
|
||||
yalnizOrgun?: boolean;
|
||||
yalnizBurslu?: boolean;
|
||||
ucretUst?: number;
|
||||
};
|
||||
|
||||
let ozellikTablosuVar: boolean | null = null;
|
||||
|
||||
function ozellikFiltreSql(o?: OzellikSuzgeci): { sql: string; args: (string | number)[] } {
|
||||
if (!o) return { sql: "", args: [] };
|
||||
const kosul: string[] = [];
|
||||
const args: (string | number)[] = [];
|
||||
if (o.dil) {
|
||||
kosul.push("ogrenim_dili LIKE ?");
|
||||
args.push(`${o.dil}%`);
|
||||
}
|
||||
if (o.yalnizOrgun) kosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
|
||||
if (o.yalnizBurslu) kosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
|
||||
if (o.ucretUst) {
|
||||
kosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
|
||||
args.push(o.ucretUst);
|
||||
}
|
||||
if (kosul.length === 0) return { sql: "", args: [] };
|
||||
if (ozellikTablosuVar == null) {
|
||||
ozellikTablosuVar =
|
||||
getDb()
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'")
|
||||
.get() !== undefined;
|
||||
}
|
||||
if (!ozellikTablosuVar) return { sql: "", args: [] };
|
||||
return {
|
||||
sql: `AND id IN (SELECT id FROM program_ozellik WHERE ${kosul.join(" AND ")})`,
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
|
||||
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
|
||||
if (grup === "vakif")
|
||||
@@ -112,6 +154,7 @@ export function dilimAra(
|
||||
il?: string;
|
||||
iller?: string[];
|
||||
uniturGrubu?: UniturGrubu;
|
||||
ozellik?: OzellikSuzgeci;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}
|
||||
@@ -132,16 +175,17 @@ export function dilimAra(
|
||||
? `AND il IN (${illerListe.map(() => "?").join(",")})`
|
||||
: "";
|
||||
const unitur = uniturFiltreSql(opts.uniturGrubu);
|
||||
const ozellik = ozellikFiltreSql(opts.ozellik);
|
||||
|
||||
const { alt, ust } = dilimAraligi(sira, dilim);
|
||||
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
|
||||
const siraKosulu =
|
||||
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
|
||||
const siraArgs = ust != null ? [alt, ust] : [alt];
|
||||
const args = [tur, onlisans, ...illerListe, ...unitur.args, ...siraArgs];
|
||||
const args = [tur, onlisans, ...illerListe, ...unitur.args, ...ozellik.args, ...siraArgs];
|
||||
|
||||
const base = `FROM programs
|
||||
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql}
|
||||
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} ${ozellik.sql}
|
||||
AND ${EFEKTIF_SIRA} IS NOT NULL
|
||||
AND ${siraKosulu}`;
|
||||
// hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa
|
||||
@@ -168,6 +212,7 @@ export function searchByRank(
|
||||
il?: string;
|
||||
iller?: string[];
|
||||
uniturGrubu?: UniturGrubu;
|
||||
ozellik?: OzellikSuzgeci;
|
||||
limitPerBucket?: number;
|
||||
} = {}
|
||||
): RankResults {
|
||||
@@ -175,6 +220,7 @@ export function searchByRank(
|
||||
il: opts.il,
|
||||
iller: opts.iller,
|
||||
uniturGrubu: opts.uniturGrubu,
|
||||
ozellik: opts.ozellik,
|
||||
limit: opts.limitPerBucket ?? 12,
|
||||
};
|
||||
const hayal = dilimAra(sira, turKey, "hayal", ortak);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz
|
||||
// node (tsx) altında import ediyor; server-only paketi orada fırlar.
|
||||
import { searchByRank } from "./db";
|
||||
import { searchByRank, type OzellikSuzgeci } from "./db";
|
||||
import type { DanismanProfili } from "./danisman-profil";
|
||||
import { yakinIller } from "./mesafe";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
type Program,
|
||||
@@ -16,10 +18,55 @@ export interface AdayProgram extends Program {
|
||||
dilim: Dilim;
|
||||
}
|
||||
|
||||
export type Gevsetilen = "profil" | "kategori" | "il";
|
||||
|
||||
export interface HavuzSonuc {
|
||||
havuz: AdayProgram[];
|
||||
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi
|
||||
gevsetildi: ("kategori" | "il")[];
|
||||
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi (sırayla)
|
||||
gevsetildi: Gevsetilen[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Danışman profilinden sert süzgeç: dil, öğretim türü, burs, ücret üst sınırı
|
||||
* (program_ozellik) ve ev iline km sınırı (il listesi). Sihirbazda il
|
||||
* seçilmişse km sınırı uygulanmaz — sihirbaz seçimi daha yeni ve açık.
|
||||
*/
|
||||
export function profilSuzgeci(
|
||||
profil: DanismanProfili | undefined,
|
||||
sihirbazIlVar: boolean,
|
||||
): { ozellik?: OzellikSuzgeci; iller?: string[]; ozet: string[] } {
|
||||
if (!profil) return { ozet: [] };
|
||||
const ozellik: OzellikSuzgeci = {};
|
||||
const ozet: string[] = [];
|
||||
if (profil.dil === "ingilizce" || profil.dil === "turkce") {
|
||||
ozellik.dil = profil.dil === "ingilizce" ? "İngilizce" : "Türkçe";
|
||||
ozet.push(`öğretim dili ${ozellik.dil}`);
|
||||
}
|
||||
if (profil.ogretimTuru === "yalniz-orgun") {
|
||||
ozellik.yalnizOrgun = true;
|
||||
ozet.push("yalnız örgün öğretim");
|
||||
}
|
||||
if (profil.burs === "yalniz-burslu") {
|
||||
ozellik.yalnizBurslu = true;
|
||||
ozet.push("vakıfta yalnız burslu");
|
||||
}
|
||||
if (profil.ucretUst) {
|
||||
ozellik.ucretUst = profil.ucretUst;
|
||||
ozet.push(`yıllık ücret en fazla ${profil.ucretUst.toLocaleString("tr-TR")} TL`);
|
||||
}
|
||||
let iller: string[] | undefined;
|
||||
if (!sihirbazIlVar && profil.evIl && profil.maxKm) {
|
||||
const yakin = yakinIller(profil.evIl, profil.maxKm);
|
||||
if (yakin.length > 0) {
|
||||
iller = yakin;
|
||||
ozet.push(`${profil.evIl} iline en fazla ${profil.maxKm} km`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ozellik: Object.keys(ozellik).length ? ozellik : undefined,
|
||||
iller,
|
||||
ozet,
|
||||
};
|
||||
}
|
||||
|
||||
const HEDEF = 24;
|
||||
@@ -89,12 +136,16 @@ function dilimEkle(r: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministik aday havuzu: Claude bu havuzdan SEÇER, asla uydurmaz.
|
||||
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) göre filtreler; havuz
|
||||
* 24'ün altına düşerse kademeli gevşetir (önce il, sonra kategori) ki liste
|
||||
* her zaman kurulabilsin.
|
||||
* Deterministik aday havuzu: yapay zekâ bu havuzdan SEÇER, asla uydurmaz.
|
||||
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) ve varsa danışman
|
||||
* profiline (dil, öğretim türü, burs, ücret, km) göre filtreler; havuz 24'ün
|
||||
* altına düşerse kademeli gevşetir (önce profil, sonra il, sonra kategori) ki
|
||||
* liste her zaman kurulabilsin. /meraklisina şeması bu sırayı anlatır.
|
||||
*/
|
||||
export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
||||
export function havuzOlustur(
|
||||
params: RaporParams,
|
||||
profil?: DanismanProfili,
|
||||
): HavuzSonuc {
|
||||
const uniturGrubu =
|
||||
params.universiteTipi && params.universiteTipi !== "farketmez"
|
||||
? params.universiteTipi
|
||||
@@ -110,6 +161,30 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
||||
? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler, p.fakulte))
|
||||
: havuz;
|
||||
|
||||
// 0. Profil süzgeci (danışmana söylenenler) — yalnız yeterli havuz bırakırsa.
|
||||
// Bırakmazsa profil gevşetilir ve aşağıdaki sihirbaz akışı aynen sürer;
|
||||
// liste yine kurulur, gevşetme yapay zekâya ve adaya söylenir.
|
||||
const suzgec = profilSuzgeci(profil, iller.length > 0);
|
||||
const profilGevsetildi: Gevsetilen[] = [];
|
||||
if (suzgec.ozellik || suzgec.iller) {
|
||||
const profilliHam = dilimEkle(
|
||||
searchByRank(params.sira, params.tur, {
|
||||
iller: iller.length > 0 ? iller : suzgec.iller,
|
||||
uniturGrubu,
|
||||
ozellik: suzgec.ozellik,
|
||||
limitPerBucket: LIMIT,
|
||||
}),
|
||||
);
|
||||
const profilliFiltreli = kategoriFiltre(profilliHam);
|
||||
if (profilliFiltreli.length >= HEDEF) {
|
||||
return {
|
||||
havuz: dilimBasinaKirp(profilliFiltreli, PROMPT_LIMIT_DILIM),
|
||||
gevsetildi: [],
|
||||
};
|
||||
}
|
||||
profilGevsetildi.push("profil");
|
||||
}
|
||||
|
||||
// 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS)
|
||||
const tamHam = dilimEkle(
|
||||
searchByRank(params.sira, params.tur, {
|
||||
@@ -122,7 +197,7 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
||||
if (tamFiltreli.length >= HEDEF) {
|
||||
return {
|
||||
havuz: dilimBasinaKirp(tamFiltreli, PROMPT_LIMIT_DILIM),
|
||||
gevsetildi: [],
|
||||
gevsetildi: profilGevsetildi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,12 +205,12 @@ export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
||||
if (kategoriler.length > 0 && tamHam.length >= HEDEF) {
|
||||
return {
|
||||
havuz: dilimBasinaKirp(tamHam, PROMPT_LIMIT_DILIM),
|
||||
gevsetildi: ["kategori"],
|
||||
gevsetildi: [...profilGevsetildi, "kategori"],
|
||||
};
|
||||
}
|
||||
|
||||
// 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön
|
||||
const gevsetildi: ("kategori" | "il")[] = [];
|
||||
const gevsetildi: Gevsetilen[] = [...profilGevsetildi];
|
||||
if (iller.length > 0) gevsetildi.push("il");
|
||||
const ilsizHam = dilimEkle(
|
||||
searchByRank(params.sira, params.tur, {
|
||||
|
||||
Reference in New Issue
Block a user