diff --git a/drizzle/0005_uneven_omega_red.sql b/drizzle/0005_uneven_omega_red.sql new file mode 100644 index 0000000..ca15953 --- /dev/null +++ b/drizzle/0005_uneven_omega_red.sql @@ -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 +); diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..f0f67b8 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1011 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "09682d29-2bdc-4235-b931-9057baf5657a", + "prevId": "79c73a04-96a6-4849-adf6-63a502c314d0", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_message_id": { + "name": "client_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_messages_client_message_id_unique": { + "name": "chat_messages_client_message_id_unique", + "columns": [ + "client_message_id" + ], + "isUnique": true + }, + "chat_user_created": { + "name": "chat_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_user_id_user_id_fk": { + "name": "chat_messages_user_id_user_id_fk", + "tableFrom": "chat_messages", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credit_ledger": { + "name": "credit_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delta": { + "name": "delta", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ledger_reason_ref": { + "name": "ledger_reason_ref", + "columns": [ + "reason", + "ref_id" + ], + "isUnique": true + }, + "ledger_user": { + "name": "ledger_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "danisman_profil": { + "name": "danisman_profil", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alanlar": { + "name": "alanlar", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "danisman_profil_user_id_user_id_fk": { + "name": "danisman_profil_user_id_user_id_fk", + "tableFrom": "danisman_profil", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_kurus": { + "name": "amount_kurus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "iyzico_token": { + "name": "iyzico_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iyzico_form_content": { + "name": "iyzico_form_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iyzico_payment_id": { + "name": "iyzico_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "orders_user": { + "name": "orders_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "orders_iyzico_token": { + "name": "orders_iyzico_token", + "columns": [ + "iyzico_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "orders_user_id_user_id_fk": { + "name": "orders_user_id_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_locks": { + "name": "report_locks", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "report_locks_user_id_user_id_fk": { + "name": "report_locks_user_id_user_id_fk", + "tableFrom": "report_locks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reports": { + "name": "reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "params": { + "name": "params", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_count": { + "name": "revision_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reports_user_id_unique": { + "name": "reports_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reports_user_id_user_id_fk": { + "name": "reports_user_id_user_id_fk", + "tableFrom": "reports", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_lists": { + "name": "saved_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items": { + "name": "items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profil": { + "name": "profil", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "saved_lists_user_id_unique": { + "name": "saved_lists_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "saved_lists_user_id_user_id_fk": { + "name": "saved_lists_user_id_user_id_fk", + "tableFrom": "saved_lists", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tadimlik_havuzu": { + "name": "tadimlik_havuzu", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kova_slug": { + "name": "kova_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tur": { + "name": "tur", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kategori_slug": { + "name": "kategori_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tip": { + "name": "tip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'genel'" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dilim": { + "name": "dilim", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gerekce": { + "name": "gerekce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "risk_notu": { + "name": "risk_notu", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trend_ozeti": { + "name": "trend_ozeti", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "program": { + "name": "program", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ornek_sira": { + "name": "ornek_sira", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uretim_at": { + "name": "uretim_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tadimlik_anahtar": { + "name": "tadimlik_anahtar", + "columns": [ + "kova_slug", + "tur", + "kategori_slug", + "tip" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_paket": { + "name": "has_paket", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "kredi_bitti_at": { + "name": "kredi_bitti_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kredi_hatirlatma_gonderildi_at": { + "name": "kredi_hatirlatma_gonderildi_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 838c808..d07b295 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1790031029618, "tag": "0004_shocking_caretaker", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1790287503094, + "tag": "0005_uneven_omega_red", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/soru/profil/route.ts b/src/app/api/soru/profil/route.ts new file mode 100644 index 0000000..a9cb5da --- /dev/null +++ b/src/app/api/soru/profil/route.ts @@ -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) }); +} diff --git a/src/app/api/soru/route.ts b/src/app/api/soru/route.ts index b791501..66ba2ac 100644 --- a/src/app/api/soru/route.ts +++ b/src/app/api/soru/route.ts @@ -9,7 +9,10 @@ import { } from "@/lib/credits"; import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client"; 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 type { RaporSonuc } from "@/features/rapor/types/rapor"; import type { RaporParams } from "@/features/rapor/types/rapor"; @@ -24,6 +27,7 @@ function sistemPromptu(opts: { params: RaporParams; listeOzeti: string; kilitli?: boolean; + profil?: DanismanProfili; }): string { const { params } = opts; const secimVar = @@ -50,7 +54,22 @@ KURALLAR: 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 — 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. - 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 @@ -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.`, ); } + 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) { parcalar.push( `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. // Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın; // 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) kullanicininRaporu(session.user.id), appDb @@ -169,6 +195,7 @@ export async function POST(request: NextRequest) { where: eq(schema.user.id, session.user.id), columns: { hasPaket: true }, }), + profilGetir(session.user.id), ]); if (!rapor?.result) { return Response.json( @@ -213,11 +240,21 @@ export async function POST(request: NextRequest) { ].join("\n") : tamOzet; + const raporParams = rapor.params as RaporParams; const system = sistemPromptu({ - params: rapor.params as RaporParams, + params: raporParams, listeOzeti, 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) if (process.env.NODE_ENV !== "production" && !anahtarVar()) { @@ -286,10 +323,13 @@ export async function POST(request: NextRequest) { let metin = ""; try { // 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, mesajlar, maxTokens: 1024, + araclar: ARACLAR, + calistir: aracCalistirici(aracBaglami), + onArac: (ad) => console.info("[soru] araç:", ad), })) { metin += parca; controller.enqueue(encoder.encode(parca)); diff --git a/src/features/rapor/components/listem-govde.tsx b/src/features/rapor/components/listem-govde.tsx index f2a6076..e37eb48 100644 --- a/src/features/rapor/components/listem-govde.tsx +++ b/src/features/rapor/components/listem-govde.tsx @@ -31,6 +31,7 @@ import { type DanismanOzeti, type IlkTercihYorumu, type Mesaj, + type ProfilSatiri, } from "./sohbet-client"; import { RevizyonKutusu } from "./revizyon-kutusu"; import { @@ -55,6 +56,7 @@ export function ListemGovde({ kredi, raporKredi, sohbetGecmisi, + danismanProfili, ozet, ilkYorum, kalanHak, @@ -76,6 +78,8 @@ export function ListemGovde({ * kapıdan geçtiği için aynı bedeli gösterir. */ raporKredi: number; sohbetGecmisi: Mesaj[]; + /** Danışmanın kaydettiği tercihler (sunucudan; çipler bununla açılır) */ + danismanProfili: ProfilSatiri[]; ozet?: DanismanOzeti; /** Sohbet boşken açılışta gösterilen 1. tercih yorumu */ ilkYorum?: IlkTercihYorumu; @@ -124,6 +128,12 @@ export function ListemGovde({ ? "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`, ]; + 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) { satirlar.push(`İlgi alanların: ${params.kategoriler.join(", ")}`); } @@ -318,6 +328,7 @@ export function ListemGovde({ (initialProfil ?? []); const router = useRouter(); + async function profiliCek(oncekiAlanlar: Set) { + 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ı // (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın. useEffect(() => { @@ -236,6 +292,8 @@ export function SohbetClient({ // Header'daki kredi pill'i layout'ta server-render edilir ve client // harcamasını görmez; bakiye orada da güncellensin. router.refresh(); + // Danışman bu cevapta profil_guncelle çağırmış olabilir + void profiliCek(new Set(profil.map((s) => s.alan))); } catch { // 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 @@ -358,6 +416,35 @@ export function SohbetClient({ + {profil.length > 0 ? ( +
+ + Kaydettiklerim + + {profil.map((s) => ( + // Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil. + + {s.metin} + + + ))} +
+ ) : null} + diff --git a/src/lib/ai/araclar.ts b/src/lib/ai/araclar.ts new file mode 100644 index 0000000..6720a86 --- /dev/null +++ b/src/lib/ai/araclar.ts @@ -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[]; + 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 = { + 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 => { + const a = (arg && typeof arg === "object" ? arg : {}) as Record; + 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 }; diff --git a/src/lib/ai/cagri.ts b/src/lib/ai/cagri.ts index 8c6c866..a2367ad 100644 --- a/src/lib/ai/cagri.ts +++ b/src/lib/ai/cagri.ts @@ -1,3 +1,4 @@ +import type OpenAI from "openai"; import { z } from "zod"; 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. 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; + /** Her araç çağrısında (günlük/analitik için) */ + onArac?: (ad: string) => void; +}): AsyncGenerator { + 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(); + 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"; + } +} diff --git a/src/lib/analitik.ts b/src/lib/analitik.ts index 147da7c..f1ff2e9 100644 --- a/src/lib/analitik.ts +++ b/src/lib/analitik.ts @@ -50,7 +50,12 @@ export type OlayAdi = // kapi_kapatildi {adim, tetik}: yalnız TAMAMLANMADAN kapanış (X, dışarı // tıklama, Vazgeç); tamamlanan kapıda atılmaz. | "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; diff --git a/src/lib/appdb/schema.ts b/src/lib/appdb/schema.ts index 19042bd..9dc160f 100644 --- a/src/lib/appdb/schema.ts +++ b/src/lib/appdb/schema.ts @@ -231,3 +231,15 @@ export const savedLists = sqliteTable("saved_lists", { createdAt: integer("created_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(), +}); diff --git a/src/lib/danisman-profil-db.ts b/src/lib/danisman-profil-db.ts new file mode 100644 index 0000000..e92703f --- /dev/null +++ b/src/lib/danisman-profil-db.ts @@ -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 { + 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 { + const mevcut = await profilGetir(userId); + const yeni: Record = { ...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 { + await appDb + .delete(schema.danismanProfil) + .where(eq(schema.danismanProfil.userId, userId)); +} diff --git a/src/lib/danisman-profil.ts b/src/lib/danisman-profil.ts new file mode 100644 index 0000000..13ecc18 --- /dev/null +++ b/src/lib/danisman-profil.ts @@ -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; +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 = { + 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 = {}; + for (const alan of PROFIL_ALANLARI) { + const deger = (ham as Record)[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(" "); +} diff --git a/src/lib/mesafe.ts b/src/lib/mesafe.ts new file mode 100644 index 0000000..faf9620 --- /dev/null +++ b/src/lib/mesafe.ts @@ -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; +}