@akredit/acik-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,14 +4,65 @@
4
4
  // adrese girer (SSRF yok). Her istekte zaman aşımı ve yanıt boyutu sınırı vardır, yanıt zod ile doğrulanır.
5
5
  //
6
6
  // Uydurma yok: künye yalnız katalog yanıtındaki alanlardan kurulur ve kaynak_bicimle biçimiyle yazılır. Başlık
7
- // ve yazarla aramada eşleşme deterministik bir benzerlik denetiminden geçmezse sonuç "doğrulanamadı"dır; istem o
8
- // eserin asla önerilmemesini söyler.
7
+ // ve yazarla aramada eşleşme deterministik bir denetimden (başlık sözcükleri, tam soyad belirteci, kayıt türü,
8
+ // verilmişse yıl) geçmezse sonuç "bulunamadi"dır.
9
+ //
10
+ // Ayrım (dış inceleme 2026-11-05): "katalogda bulundu" yalnız eserin katalogda var olduğunu söyler; "ders kaynağı
11
+ // olarak uygun" kararı hocanındır. Türkçe kitapların çoğu bu kataloglarda yoktur: bulunamayan eser uydurma sayılmaz
12
+ // ama doğrulanmış da sayılmaz; yalnız hocanın verdiği bilgiyle "hoca_beyani" etiketiyle yazılabilir.
13
+ //
14
+ // Kötüye kullanım sınırı: çağrı başına en çok 5 kaynak, süreç genelinde en çok 4 eşzamanlı dış istek, yanıt akışla
15
+ // okunur ve sınırda kesilir. Adres başına ayrı kota HTTP katmanındadır (http.ts).
16
+ //
17
+ // İmza: bulunan her kayıt, sürecin gizli anahtarıyla künye+bağlantı üzerinden imzalanır. Bologna araçları kaynağı
18
+ // "doğrulanmış" saymak için bu imzayı arar; asistan imzayı kendisi üretemez.
19
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
9
20
  import { z } from "zod";
10
21
  import { kunyeBicimle } from "./kaynak.js";
11
22
  import { AracHatasi, arac, KOD_KAYNAGI } from "./temel.js";
12
23
  export const IZINLI_HOSTLAR = ["api.crossref.org", "openlibrary.org", "www.googleapis.com"];
13
24
  export const ZAMAN_ASIMI_MS = 8000;
14
25
  export const EN_BUYUK_YANIT = 1_000_000;
26
+ /** Çağrı başına en çok kaynak. */
27
+ export const EN_COK_KAYNAK = 5;
28
+ /** Süreç genelinde aynı anda en çok dış istek. */
29
+ export const ESZAMANLI_DIS_ISTEK = 4;
30
+ // ---------------------------------------------------------------- Eşzamanlılık sınırı (semafor)
31
+ let aktifIstek = 0;
32
+ const bekleyenler = [];
33
+ /** Süreç genelindeki dış istek semaforu. Sınır doluysa sıra bekler. */
34
+ export async function sinirli(is) {
35
+ if (aktifIstek >= ESZAMANLI_DIS_ISTEK)
36
+ await new Promise((coz) => bekleyenler.push(coz));
37
+ aktifIstek += 1;
38
+ try {
39
+ return await is();
40
+ }
41
+ finally {
42
+ aktifIstek -= 1;
43
+ bekleyenler.shift()?.();
44
+ }
45
+ }
46
+ // ---------------------------------------------------------------- İmza
47
+ // Anahtar ilk kullanımda üretilir: modül tarayıcı araçlarına da (/deneyin) girer ve orada imza hiç kullanılmaz.
48
+ let imzaAnahtari;
49
+ function anahtar() {
50
+ if (!imzaAnahtari) {
51
+ const ortam = typeof process !== "undefined" ? process.env.AKREDIT_MCP_IMZA_ANAHTARI : undefined;
52
+ imzaAnahtari = ortam && ortam.length >= 16 ? Buffer.from(ortam) : randomBytes(32);
53
+ }
54
+ return imzaAnahtari;
55
+ }
56
+ /** Katalog kaydının imzası: künye ve bağlantı üzerinden HMAC-SHA256 (ilk 32 onaltılık hane). */
57
+ export function kaynakImzasi(kunye, baglanti) {
58
+ return createHmac("sha256", anahtar()).update(`${kunye}\n${baglanti}`).digest("hex").slice(0, 32);
59
+ }
60
+ /** İmza bu sürecin kaynak_dogrula çıktısına mı ait? */
61
+ export function kaynakImzasiDogru(kunye, baglanti, imza) {
62
+ const beklenen = Buffer.from(kaynakImzasi(kunye, baglanti));
63
+ const verilen = Buffer.from(String(imza));
64
+ return beklenen.length === verilen.length && timingSafeEqual(beklenen, verilen);
65
+ }
15
66
  const DOI_DESENI = /^10\.\d{4,9}\/[^\s]{1,200}$/u;
16
67
  /** "https://doi.org/10.x/y", "doi:10.x/y" ya da "10.x/y" → "10.x/y". Geçersizse null. */
17
68
  export function doiTemizle(d) {
@@ -47,22 +98,33 @@ export function sadele(s) {
47
98
  .split(/\s+/u)
48
99
  .filter((x) => x.length > 1);
49
100
  }
50
- /** Sorgu başlığının sözcüklerinin en az %80'i bulunan başlıkta var mı, ve bulunanın en az %60'ı sorguda var mı. */
101
+ /**
102
+ * Sorgu başlığının sözcüklerinin en az %80'i bulunan başlıkta var mı, ve bulunanın en az %60'ı sorguda var mı.
103
+ * Kısa (en çok iki sözcüklü) başlıklarda ("Veri Yapıları", "Genel Kimya") sözcük kümeleri birebir aynı olmalı.
104
+ */
51
105
  export function baslikEslesir(sorgu, bulunan) {
52
- const a = sadele(sorgu);
106
+ const a = [...new Set(sadele(sorgu))];
53
107
  const b = new Set(sadele(bulunan));
54
108
  if (!a.length || !b.size)
55
109
  return false;
56
110
  const ortak = a.filter((x) => b.has(x)).length;
111
+ if (a.length <= 2)
112
+ return ortak === a.length && b.size === a.length;
57
113
  return ortak / a.length >= 0.8 && ortak / b.size >= 0.6;
58
114
  }
59
- /** Sorgudaki yazar soyadı (son sözcük ya da virgülden önceki kısım) bulunan yazarlardan birinde geçiyor mu. */
115
+ /**
116
+ * Sorgudaki yazar soyadı (son sözcük ya da virgülden önceki kısım) bulunan yazarlardan birinde TAM belirteç olarak
117
+ * geçiyor mu. Alt dize yetmez: "Yılmaz" sorgusu "Yılmazer" ile eşleşmez.
118
+ */
60
119
  export function yazarEslesir(sorgu, yazarlar) {
61
120
  const soyad = sorgu.includes(",") ? (sorgu.split(",")[0] ?? "") : (sorgu.trim().split(/\s+/u).at(-1) ?? "");
62
- const s = sadele(soyad).join(" ");
63
- if (!s)
121
+ const s = sadele(soyad);
122
+ if (!s.length)
64
123
  return false;
65
- return yazarlar.some((y) => sadele(y).join(" ").includes(s));
124
+ return yazarlar.some((y) => {
125
+ const b = new Set(sadele(y));
126
+ return s.every((x) => b.has(x));
127
+ });
66
128
  }
67
129
  // ---------------------------------------------------------------- Güvenli istek
68
130
  async function getir(f, adres) {
@@ -70,31 +132,58 @@ async function getir(f, adres) {
70
132
  throw new Error("İzinsiz host");
71
133
  if (adres.hostname === "www.googleapis.com" && !adres.pathname.startsWith("/books/"))
72
134
  throw new Error("İzinsiz yol");
73
- let y;
74
- try {
75
- y = await f(adres.toString(), {
76
- signal: AbortSignal.timeout(ZAMAN_ASIMI_MS),
77
- redirect: "error",
78
- headers: { accept: "application/json", "user-agent": "akredit-acik-mcp (https://akredit.org)" },
79
- });
80
- }
81
- catch {
82
- return null;
83
- }
84
- if (!y.ok)
85
- return null;
86
- const uzunluk = Number(y.headers.get("content-length") ?? "0");
87
- if (uzunluk > EN_BUYUK_YANIT)
88
- return null;
89
- const metin = await y.text();
90
- if (metin.length > EN_BUYUK_YANIT)
91
- return null;
135
+ return sinirli(async () => {
136
+ let y;
137
+ try {
138
+ y = await f(adres.toString(), {
139
+ signal: AbortSignal.timeout(ZAMAN_ASIMI_MS),
140
+ redirect: "error",
141
+ headers: { accept: "application/json", "user-agent": "akredit-acik-mcp (https://akredit.org)" },
142
+ });
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ if (!y.ok)
148
+ return null;
149
+ const uzunluk = Number(y.headers.get("content-length") ?? "0");
150
+ if (uzunluk > EN_BUYUK_YANIT)
151
+ return null;
152
+ const metin = await sinirliMetin(y, EN_BUYUK_YANIT);
153
+ if (metin === null)
154
+ return null;
155
+ try {
156
+ return JSON.parse(metin);
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ });
162
+ }
163
+ /** Yanıt gövdesini bayt sayarak okur; sınır aşılırsa akışı iptal edip null döner. */
164
+ export async function sinirliMetin(y, enBuyuk) {
165
+ if (!y.body)
166
+ return "";
167
+ const okuyucu = y.body.getReader();
168
+ const parcalar = [];
169
+ let toplam = 0;
92
170
  try {
93
- return JSON.parse(metin);
171
+ for (;;) {
172
+ const { done, value } = await okuyucu.read();
173
+ if (done)
174
+ break;
175
+ toplam += value.byteLength;
176
+ if (toplam > enBuyuk) {
177
+ await okuyucu.cancel().catch(() => { });
178
+ return null;
179
+ }
180
+ parcalar.push(value);
181
+ }
94
182
  }
95
183
  catch {
96
184
  return null;
97
185
  }
186
+ return Buffer.concat(parcalar).toString("utf8");
98
187
  }
99
188
  // ---------------------------------------------------------------- Katalog yanıtları (zod)
100
189
  const CrossrefEser = z.object({
@@ -134,24 +223,39 @@ const GoogleCilt = z.object({
134
223
  }),
135
224
  });
136
225
  const GoogleYanit = z.object({ totalItems: z.number().optional(), items: z.array(GoogleCilt).optional() });
226
+ const HOCA_BEYANI = "Katalogda bulunamadı. Hoca bu eseri kendisi kullanıyorsa künyesini hocanın verdiği bilgiyle 'hoca_beyani' etiketiyle yazın; asistan künye alanı uydurmaz.";
227
+ const bulunamadi = (neden) => ({ durum: "bulunamadi", neden, hocaBeyani: HOCA_BEYANI });
137
228
  const yilAl = (s) => {
138
229
  const m = s?.match(/\b(1[4-9]\d{2}|20\d{2}|2100)\b/u);
139
230
  return m ? Number(m[1]) : undefined;
140
231
  };
141
- const kur = (k, baglanti, katalog) => {
232
+ const kur = (k, baglanti, katalog, kayitTuru = k.tur === "makale" ? "makale" : k.tur === "kitap_bolumu" ? "kitap_bolumu" : "kitap") => {
142
233
  const g = { ...k, kurumYazar: false };
143
234
  const { kunye, eksikler } = kunyeBicimle(g);
144
235
  return {
145
- durum: "dogrulandi",
236
+ durum: "katalogda_bulundu",
146
237
  kunye,
147
238
  eksikler,
148
239
  baglanti,
149
240
  katalog,
241
+ kayitTuru,
150
242
  baslik: k.baslik,
151
243
  yazarlar: k.yazarlar,
152
244
  ...(k.yil !== undefined ? { yil: k.yil } : {}),
245
+ kimlik_eslesmesi: { yontem: "baslik_yazar", denetlenen: [] },
246
+ bibliyografik_tamlik: eksikler.length ? "eksik" : "tam",
247
+ ders_kaynagi_uygunlugu: "hoca karar verir",
248
+ imza: kaynakImzasi(kunye, baglanti),
153
249
  };
154
250
  };
251
+ const CROSSREF_KITAP = new Set(["book", "monograph", "edited-book", "reference-book", "book-set", "book-series"]);
252
+ const crossrefTuru = (t) => t === "journal-article"
253
+ ? "makale"
254
+ : t === "book-chapter" || t === "book-section" || t === "book-part"
255
+ ? "kitap_bolumu"
256
+ : t && CROSSREF_KITAP.has(t)
257
+ ? "kitap"
258
+ : "diger";
155
259
  function crossrefKunye(e) {
156
260
  const baslik = e.title?.[0]?.trim();
157
261
  if (!baslik)
@@ -161,7 +265,8 @@ function crossrefKunye(e) {
161
265
  .filter(Boolean);
162
266
  const yil = e.issued?.["date-parts"]?.[0]?.[0] ?? undefined;
163
267
  const kap = e["container-title"]?.[0];
164
- const tur = e.type === "journal-article" ? "makale" : e.type === "book-chapter" ? "kitap_bolumu" : "kitap";
268
+ const kayit = crossrefTuru(e.type);
269
+ const tur = kayit === "makale" ? "makale" : kayit === "kitap_bolumu" ? "kitap_bolumu" : "kitap";
165
270
  const doi = e.DOI;
166
271
  return kur({
167
272
  tur,
@@ -180,7 +285,7 @@ function crossrefKunye(e) {
180
285
  ...(e.issue ? { sayi: e.issue } : {}),
181
286
  ...(e.page ? { sayfalar: e.page } : {}),
182
287
  doi,
183
- }, `https://doi.org/${doi}`, "Crossref");
288
+ }, `https://doi.org/${doi}`, "Crossref", kayit);
184
289
  }
185
290
  function googleKunye(c) {
186
291
  const v = c.volumeInfo;
@@ -197,10 +302,41 @@ function googleKunye(c) {
197
302
  const CROSSREF = "https://api.crossref.org/works";
198
303
  const OPENLIBRARY = "https://openlibrary.org/api/books";
199
304
  const GOOGLE = "https://www.googleapis.com/books/v1/volumes";
305
+ const KAYIT_ADI = {
306
+ kitap: "kitap",
307
+ makale: "dergi makalesi",
308
+ kitap_bolumu: "kitap bölümü",
309
+ diger: "başka türde bir kayıt (bildiri, rapor...)",
310
+ };
311
+ /** Aranan tür ve yıl verildiyse kayıtla uyuşuyor mu; uyuşmuyorsa nedeni. */
312
+ function ekDenetim(k, s) {
313
+ if (s.tur && s.tur !== "herhangi" && k.kayitTuru !== s.tur)
314
+ return `Katalogdaki kayıt ${KAYIT_ADI[k.kayitTuru]}; aranan ${KAYIT_ADI[s.tur]}. Aynı eser sayılmadı.`;
315
+ if (s.yil !== undefined) {
316
+ const y = typeof k.yil === "number" ? k.yil : yilAl(String(k.yil ?? ""));
317
+ if (y === undefined)
318
+ return "Katalog kaydında yıl yok; verilen yılla karşılaştırılamadı.";
319
+ if (y !== s.yil)
320
+ return `Katalogdaki yıl ${y}, verilen yıl ${s.yil}. Aynı baskı sayılmadı.`;
321
+ }
322
+ return null;
323
+ }
324
+ function sonla(k, yontem, s, bulunamadiNedeni) {
325
+ if (!k)
326
+ return bulunamadi(bulunamadiNedeni);
327
+ const sorun = ekDenetim(k, s);
328
+ if (sorun)
329
+ return bulunamadi(sorun);
330
+ const denetlenen = [
331
+ yontem === "doi" ? "DOI" : yontem === "isbn" ? "ISBN (sağlama basamağı geçerli)" : "başlık sözcükleri, tam soyad",
332
+ ...(s.tur && s.tur !== "herhangi" ? ["kayıt türü"] : []),
333
+ ...(s.yil !== undefined ? ["yıl"] : []),
334
+ ];
335
+ return { ...k, kimlik_eslesmesi: { yontem, denetlenen } };
336
+ }
200
337
  async function doiIle(f, doi) {
201
338
  const y = CrossrefTek.safeParse(await getir(f, new URL(`${CROSSREF}/${encodeURIComponent(doi)}`)));
202
- const k = y.success ? crossrefKunye(y.data.message) : null;
203
- return k ?? { durum: "dogrulanamadi", neden: "Bu DOI Crossref kataloğunda bulunamadı." };
339
+ return y.success ? crossrefKunye(y.data.message) : null;
204
340
  }
205
341
  async function isbnIle(f, isbn) {
206
342
  const ol = new URL(OPENLIBRARY);
@@ -228,18 +364,20 @@ async function isbnIle(f, isbn) {
228
364
  const c = gy.success
229
365
  ? gy.data.items?.find((x) => (x.volumeInfo.industryIdentifiers ?? []).some((i) => i.identifier.replace(/-/gu, "") === isbn))
230
366
  : undefined;
231
- return c ? googleKunye(c) : { durum: "dogrulanamadi", neden: "Bu ISBN Open Library ve Google Books'ta bulunamadı." };
367
+ return c ? googleKunye(c) : null;
232
368
  }
233
- async function baslikIle(f, baslik, yazar) {
369
+ async function baslikIle(f, baslik, yazar, s) {
370
+ const uygun = (k) => baslikEslesir(baslik, k.baslik) && yazarEslesir(yazar, k.yazarlar) && ekDenetim(k, s) === null;
234
371
  const g = new URL(GOOGLE);
235
372
  g.searchParams.set("q", `intitle:${baslik} inauthor:${yazar}`);
236
373
  g.searchParams.set("maxResults", "5");
237
374
  const gy = GoogleYanit.safeParse(await getir(f, g));
238
- const c = gy.success
239
- ? gy.data.items?.find((x) => baslikEslesir(baslik, x.volumeInfo.title) && yazarEslesir(yazar, x.volumeInfo.authors ?? []))
240
- : undefined;
241
- if (c)
242
- return googleKunye(c);
375
+ if (gy.success)
376
+ for (const x of gy.data.items ?? []) {
377
+ const k = googleKunye(x);
378
+ if (baslikEslesir(baslik, x.volumeInfo.title) && uygun(k))
379
+ return k;
380
+ }
243
381
  const cr = new URL(CROSSREF);
244
382
  cr.searchParams.set("query.bibliographic", baslik);
245
383
  cr.searchParams.set("query.author", yazar);
@@ -248,13 +386,10 @@ async function baslikIle(f, baslik, yazar) {
248
386
  if (cy.success)
249
387
  for (const e of cy.data.message.items) {
250
388
  const k = crossrefKunye(e);
251
- if (k && baslikEslesir(baslik, k.baslik) && yazarEslesir(yazar, k.yazarlar))
389
+ if (k && uygun(k))
252
390
  return k;
253
391
  }
254
- return {
255
- durum: "dogrulanamadi",
256
- neden: "Bu başlık ve yazarla eşleşen eser Google Books ve Crossref kataloglarında bulunamadı.",
257
- };
392
+ return null;
258
393
  }
259
394
  const KaynakSorgusu = z
260
395
  .object({
@@ -262,25 +397,31 @@ const KaynakSorgusu = z
262
397
  isbn: z.string().trim().max(30).optional(),
263
398
  baslik: z.string().trim().min(2).max(300).optional(),
264
399
  yazar: z.string().trim().min(2).max(120).optional(),
400
+ tur: z
401
+ .enum(["kitap", "makale", "kitap_bolumu", "herhangi"])
402
+ .optional()
403
+ .describe("Aranan kayıt türü. Kitap aranıyorsa dergi makalesi kabul edilmez. ISBN verilirse varsayılan kitap."),
404
+ yil: z.number().int().min(1400).max(2100).optional().describe("Biliniyorsa yayın yılı; kayıtla birebir uymalı"),
265
405
  })
266
406
  .refine((k) => k.doi || k.isbn || (k.baslik && k.yazar), {
267
407
  message: "DOI, ISBN ya da başlık ile yazar verin.",
268
408
  });
269
409
  /** Tek kaynağı doğrular. DOI önce, sonra ISBN, sonra başlık+yazar. */
270
410
  export async function kaynakDogrula(f, k) {
411
+ const ek = { tur: k.tur, yil: k.yil };
271
412
  if (k.doi) {
272
413
  const d = doiTemizle(k.doi);
273
414
  if (!d)
274
- return { durum: "dogrulanamadi", neden: "DOI biçimi geçersiz (10.xxxx/... olmalı)." };
275
- return doiIle(f, d);
415
+ return bulunamadi("DOI biçimi geçersiz (10.xxxx/... olmalı).");
416
+ return sonla(await doiIle(f, d), "doi", ek, "Bu DOI Crossref kataloğunda bulunamadı.");
276
417
  }
277
418
  if (k.isbn) {
278
419
  const i = isbnTemizle(k.isbn);
279
420
  if (!i)
280
- return { durum: "dogrulanamadi", neden: "ISBN geçersiz (denetim basamağı tutmuyor)." };
281
- return isbnIle(f, i);
421
+ return bulunamadi("ISBN geçersiz (sağlama basamağı tutmuyor).");
422
+ return sonla(await isbnIle(f, i), "isbn", { ...ek, tur: k.tur ?? "kitap" }, "Bu ISBN Open Library ve Google Books'ta bulunamadı.");
282
423
  }
283
- return baslikIle(f, k.baslik ?? "", k.yazar ?? "");
424
+ return sonla(await baslikIle(f, k.baslik ?? "", k.yazar ?? "", ek), "baslik_yazar", ek, "Bu başlık ve yazarla (tam soyad, verildiyse tür ve yıl) eşleşen eser Google Books ve Crossref kataloglarında bulunamadı. Türkçe kitapların çoğu bu kataloglarda yoktur.");
284
425
  }
285
426
  const KATALOG_KAYNAKLARI = [
286
427
  { ad: "Crossref REST API", adres: "https://api.crossref.org" },
@@ -290,14 +431,14 @@ const KATALOG_KAYNAKLARI = [
290
431
  export const kaynakDogrulaAraci = arac({
291
432
  ad: "kaynak_dogrula",
292
433
  baslik: "Kaynağın gerçekten var olduğunu doğrula",
293
- aciklama: "DOI, ISBN ya da başlık ile yazar verilen eserleri açık kataloglarda (Crossref, Open Library, Google Books) arar. Bulursa katalogdaki bilgiden künye ve açılabilir bağlantı döndürür; bulamazsa 'doğrulanamadı' der. Önerilecek her kaynak önce bu araçtan geçer.",
294
- sonrakiAdim: "Yalnız durumu 'dogrulandi' olan kaynakları hocaya künyesi ve bağlantısıyla numaralı seçenek olarak gösterin. 'dogrulanamadi' olan kaynağı asla önermeyin, künyesini yazmayın; yerine başka bir aday arayıp yeniden doğrulayın.",
434
+ aciklama: "DOI, ISBN ya da başlık ile yazar verilen eserleri (çağrı başına en çok 5) açık kataloglarda (Crossref, Open Library, Google Books) arar. Eşleşme için tam soyad, başlık sözcükleri, verildiyse kayıt türü ve yıl denetlenir. Bulursa katalogdaki bilgiden künye, bağlantı ve imza döndürür ('katalogda_bulundu'); bulamazsa 'bulunamadi' der. Katalogda bulunmak ders kaynağı olarak uygunluk demek değildir; ona hoca karar verir.",
435
+ sonrakiAdim: "Yalnız 'katalogda_bulundu' olan kaynakları hocaya künyesi ve bağlantısıyla gösterin; uygunluğuna hoca karar verir. Bologna araçlarına kaynağı { kunye, dogrulama: { durum, baglanti, imza } } olarak aynen verin. 'bulunamadi' olan kaynağı asla kendiniz önermeyin; hoca o eseri kullandığını söylerse yalnız hocanın verdiği bilgiyle dogrulama: 'hoca_beyani' olarak yazın.",
295
436
  kunyeAlanlari: ["kaynaklar"],
296
437
  disAg: true,
297
438
  // Künye ve katalog numaraları kişisel veri değildir; ISBN gibi uzun sayılar numara deseniyle karışmasın.
298
439
  kisiselVeriKapsami: "yok",
299
440
  girdi: z.object({
300
- kaynaklar: z.array(KaynakSorgusu).min(1).max(10),
441
+ kaynaklar: z.array(KaynakSorgusu).min(1).max(EN_COK_KAYNAK),
301
442
  }),
302
443
  calistir: async ({ kaynaklar }, b) => {
303
444
  const f = b.fetch ?? globalThis.fetch;
@@ -305,13 +446,13 @@ export const kaynakDogrulaAraci = arac({
305
446
  throw new AracHatasi("Bu ortamda dış katalog sorgusu yapılamıyor. Kaynakları şimdilik önermeyin.");
306
447
  const sonuclar = await Promise.all(kaynaklar.map((k) => kaynakDogrula(f, k)));
307
448
  const liste = sonuclar.map((s, i) => ({ no: i + 1, ...s }));
308
- const dogrulanan = liste.filter((s) => s.durum === "dogrulandi").length;
449
+ const bulunan = liste.filter((s) => s.durum === "katalogda_bulundu").length;
309
450
  return {
310
451
  sonuc: {
311
452
  kaynaklar: liste,
312
- dogrulanan,
313
- dogrulanamayan: liste.length - dogrulanan,
314
- kural: "Doğrulanamayan kaynak önerilmez. Künye katalog bilgisinden kurulur; katalogda olmayan alan [EKSİK] kalır.",
453
+ katalogdaBulunan: bulunan,
454
+ bulunamayan: liste.length - bulunan,
455
+ kural: "Katalogda bulunmayan kaynak asistanca önerilmez; hoca kullandığını söylerse 'hoca_beyani' etiketiyle yazılır. Künye katalog bilgisinden kurulur; katalogda olmayan alan [EKSİK] kalır. Ders kaynağı olarak uygunluğa hoca karar verir.",
315
456
  },
316
457
  kaynak: [KOD_KAYNAGI("apps/acik-mcp/src/kaynak-dogrula.ts", "Akredit kaynak doğrulayıcı"), ...KATALOG_KAYNAKLARI],
317
458
  };
@@ -3,6 +3,8 @@
3
3
  * `kunyeAnahtarlari`: altındaki metinler künye kuralıyla (yazar adı serbest) değerlendirilen alan adları.
4
4
  */
5
5
  export type KisiselVeriKapsami = "yok" | "ogrenci" | "tam";
6
+ /** HTML ise yalnız görünen metni bırakır (betik, stil, yorum, etiket ve öznitelikler atılır). Değilse aynen döner. */
7
+ export declare function gorunenMetin(x: string): string;
6
8
  export declare function kisiselVeriTurleri(girdi: unknown, kunyeAnahtarlari?: readonly string[], kapsam?: KisiselVeriKapsami): string[];
7
9
  export declare function kisiselVeriIletisi(turler: readonly string[]): string;
8
10
  /** Girdide giriş bilgisi var mı? Yalnız evet ya da hayır döner, değer döndürmez. */
@@ -12,4 +14,4 @@ export declare function girisBilgisiAyikla(metin: string): {
12
14
  metin: string;
13
15
  atildi: boolean;
14
16
  };
15
- export declare const GIRIS_BILGISI_ILETISI = "G\u00F6nderdi\u011Finiz veride parola, do\u011Frulama kodu ya da oturum \u00E7erezi olabilecek bir ifade var. Bu bilgiler yapay zek\u00E2ya ve Akredit'e hi\u00E7 verilmez; yaln\u0131z OBS'nin kendi giri\u015F ekran\u0131na siz yazars\u0131n\u0131z. O k\u0131sm\u0131 silip yeniden g\u00F6nderin. Parolan\u0131z\u0131 bir sohbete yazd\u0131ysan\u0131z OBS'de de\u011Fi\u015Ftirmeniz iyi olur.";
17
+ export declare const GIRIS_BILGISI_ILETISI = "G\u00F6nderdi\u011Finiz veride parola, do\u011Frulama kodu ya da oturum \u00E7erezi olabilecek bir ifade var. Akredit bu bilgileri istemez; g\u00F6r\u00FClen istek reddedildi ve kaydedilmedi. Bunlar\u0131 yaln\u0131z OBS'nin kendi giri\u015F ekran\u0131na siz yazars\u0131n\u0131z. O k\u0131sm\u0131 silip yeniden g\u00F6nderin. Parolan\u0131z\u0131 bir sohbete yazd\u0131ysan\u0131z OBS'de de\u011Fi\u015Ftirmeniz iyi olur.";
@@ -3,16 +3,20 @@
3
3
  //
4
4
  // Kapsam (İP-49): hocanın kendi sayfasında yayımlanmış görev bilgisi (Dersi Verenler, Koordinatör, iletişim)
5
5
  // kişisel veri sayılıp atılmaz; olduğu gibi okunur ve korunur. Araçlar kapsamı kendisi seçer:
6
- // - "yok": yalnız giriş bilgisi denetlenir (Bologna araçları).
7
- // - "ogrenci" (varsayılan): öğrenci ve kimlik verisi (T.C. kimlik no, öğrenci numarası, IBAN) reddedilir.
6
+ // - "yok": yalnız giriş bilgisi denetlenir (kaynak doğrulama: künye ve katalog numaraları).
7
+ // - "ogrenci" (varsayılan; Bologna araçları da bunu kullanır): öğrenci ve kimlik verisi (T.C. kimlik no, öğrenci
8
+ // numarası, IBAN) reddedilir. Yayımlanmış öğretim elemanı adları serbesttir.
8
9
  // - "tam": bütün desenler (not tablosu ve ÇK/PÇ: öğrenci adı ve numarası takma adla gelmelidir).
9
10
  //
10
11
  // Künye kuralı: kaynakçadaki yazar adları yayımlanmış künyedir, kişisel veri sayılmaz. Bu yüzden künye alanlarında
11
- // (araç bunları adıyla bildirir) kişi adı deseni aranmaz; e-posta, telefon, kimlik numarası gibi öbür desenler
12
- // yine aranır.
12
+ // (araç bunları adıyla bildirir) kişi adı ve etiketsiz uzun sayı (ISBN, katalog no) deseni aranmaz; e-posta,
13
+ // T.C. kimlik no, IBAN gibi öbür desenler yine aranır.
13
14
  //
14
- // Giriş bilgisi (ilke 8): parola, doğrulama kodu ve oturum çerezi sunucuya hiç girmez. Böyle bir desen görülürse
15
- // çağrı reddedilir; değer yanıtta tekrarlanmaz.
15
+ // Giriş bilgisi (ilke 8): parola, doğrulama kodu ve oturum çerezi istenmez. Yine de gönderilirse (sunucu isteği
16
+ // almadan bunu engelleyemez) desen görülür görülmez çağrı reddedilir, değer kaydedilmez ve yanıtta tekrarlanmaz.
17
+ //
18
+ // Başarım (dış inceleme 2026-11-05): HTML girdide desenler bütün kaynağa değil, yalnız görünen metne uygulanır
19
+ // (betik, stil, etiket ve öznitelikler önce atılır). Bağlantı adreslerindeki sayılar da böylece numara sayılmaz.
16
20
  import { maskele } from "../../../packages/yz/src/index.js";
17
21
  const TUR_ADI = {
18
22
  KİŞİ: "kişi adı",
@@ -41,17 +45,28 @@ function metinler(deger, kunyeAnahtarlari, cikti = { genel: [], kunye: [] }, kun
41
45
  /** "ogrenci" kapsamında aranan türler; öbürleri (ad, e-posta, telefon, adres, imza) kapalıdır. */
42
46
  const OGRENCI_TURLERI = ["TCKN", "ÖĞRENCİ-NO", "IBAN"];
43
47
  const TUM_TURLER = Object.keys(TUR_ADI);
48
+ /** HTML ise yalnız görünen metni bırakır (betik, stil, yorum, etiket ve öznitelikler atılır). Değilse aynen döner. */
49
+ export function gorunenMetin(x) {
50
+ if (!/<\/?[a-z][\w-]*[\s>/]/iu.test(x))
51
+ return x;
52
+ return x
53
+ .replace(/<!--[\s\S]*?-->/gu, " ")
54
+ .replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1\s*>/giu, " ")
55
+ .replace(/<[^>]{0,2000}>/gu, " ")
56
+ .replace(/&nbsp;/gu, " ");
57
+ }
44
58
  export function kisiselVeriTurleri(girdi, kunyeAnahtarlari = [], kapsam = "tam") {
45
59
  if (kapsam === "yok")
46
60
  return [];
47
61
  const kapali = kapsam === "ogrenci" ? TUM_TURLER.filter((t) => !OGRENCI_TURLERI.includes(t)) : [];
62
+ const kunyeKapali = [...new Set([...kapali, "KİŞİ", "ÖĞRENCİ-NO"])];
48
63
  const turler = new Set();
49
64
  const m = metinler(girdi, new Set(kunyeAnahtarlari));
50
65
  for (const x of m.genel)
51
- for (const e of maskele(x, { kapali }).eslemeler)
66
+ for (const e of maskele(gorunenMetin(x), { kapali }).eslemeler)
52
67
  turler.add(e.tur);
53
68
  for (const x of m.kunye)
54
- for (const e of maskele(x, { kapali: [...new Set([...kapali, "KİŞİ"])] }).eslemeler)
69
+ for (const e of maskele(gorunenMetin(x), { kapali: kunyeKapali }).eslemeler)
55
70
  turler.add(e.tur);
56
71
  return [...turler].map((t) => TUR_ADI[t]);
57
72
  }
@@ -85,4 +100,4 @@ export function girisBilgisiAyikla(metin) {
85
100
  .replace(/\b(set-cookie|cookie)\s*:[^\n]*/giu, at);
86
101
  return { metin: yeni, atildi };
87
102
  }
88
- export const GIRIS_BILGISI_ILETISI = "Gönderdiğiniz veride parola, doğrulama kodu ya da oturum çerezi olabilecek bir ifade var. Bu bilgiler yapay zekâya ve Akredit'e hiç verilmez; yalnız OBS'nin kendi giriş ekranına siz yazarsınız. O kısmı silip yeniden gönderin. Parolanızı bir sohbete yazdıysanız OBS'de değiştirmeniz iyi olur.";
103
+ export const GIRIS_BILGISI_ILETISI = "Gönderdiğiniz veride parola, doğrulama kodu ya da oturum çerezi olabilecek bir ifade var. Akredit bu bilgileri istemez; görülen istek reddedildi ve kaydedilmedi. Bunları yalnız OBS'nin kendi giriş ekranına siz yazarsınız. O kısmı silip yeniden gönderin. Parolanızı bir sohbete yazdıysanız OBS'de değiştirmeniz iyi olur.";
@@ -8,6 +8,8 @@ export declare function tabloOku(metin: string): {
8
8
  };
9
9
  /** Kimlik sütunları: başlıktan ya da içerikten (çoğunluğu harfli metin ya da 7+ haneli sayı). */
10
10
  export declare function kimlikSutunlari(satirlar: string[][]): number[];
11
+ /** Kimlik sütunu olmayan tablonun başına o1, o2 takma ad sütunu ekler. Başlıkta zaten takma ad varsa dokunmaz. */
12
+ export declare function takmaAdEkle(metin: string): string;
11
13
  /** Tablodan kimlik sütunlarını atar, başa o1, o2 takma adı koyar. Yalnız bellekte çalışır. */
12
14
  export declare function notTablosuAyikla(metin: string): {
13
15
  tablo: string;
@@ -24,6 +26,10 @@ export interface Sutun {
24
26
  }
25
27
  /** Sütun başlığından araç, soru, tam puan ve ÇK eşlemesi sezgisi. "Vize S1 (ÇK2) /20" gibi. */
26
28
  export declare function sutunCoz(baslik: string): Sutun | null;
29
+ /** Kişisel veri görülünce verilen ileti (aynen). */
30
+ export declare const NOT_TABLOSU_KISISEL_ILETI = "Ki\u015Fisel veriyi yapay zek\u00E2 sohbetine yap\u0131\u015Ft\u0131rmay\u0131n: Excel'de Ad, Soyad, Numara s\u00FCtunlar\u0131n\u0131 silip yaln\u0131z not s\u00FCtunlar\u0131n\u0131 yap\u0131\u015Ft\u0131r\u0131n ya da verinin taray\u0131c\u0131n\u0131zdan \u00E7\u0131kmad\u0131\u011F\u0131 hesaplay\u0131c\u0131y\u0131 kullan\u0131n: https://akredit.org/deneyin";
31
+ /** Tabloda kimlik sütunu (ad, soyad, numara...) ya da 9-11 haneli numara var mı? Varsa red iletisi döner. */
32
+ export declare function notTablosuKisiselVeri(metin: string): string | null;
27
33
  export declare const SUTUN_ESLEME_REHBERI: string[];
28
34
  export declare const notTablosuAyristirAraci: import("./temel.ts").AcikArac<z.ZodObject<{
29
35
  tablo: z.ZodString;
@@ -1,6 +1,10 @@
1
- // Excel ya da CSV'den yapıştırılan not tablosunu ck_pc_hesapla girdisine hazırlar. Öğrenci kimliği (ad, soyad,
2
- // numara, e-posta) ayrıştırmadan önce bellekte atılır ve satır sırasına göre o1, o2, ... takma adı verilir; atılan
3
- // değerler hiçbir yere yazılmaz. Sütun başlıklarından ölçme aracı, soru numarası, tam puan ve ders çıktısı (ÇK)
1
+ // Excel ya da CSV'den yapıştırılan not tablosunu ck_pc_hesapla girdisine hazırlar. Öğrenciler satır sırasına göre
2
+ // o1, o2, ... takma adı alır.
3
+ //
4
+ // Kişisel veri (dış inceleme 2026-11-05): tablo sohbete yapıştırıldığı anda yapay zekâ sağlayıcısına gitmiştir;
5
+ // sunucunun sonradan sütun atması bunu geri almaz. Bu yüzden ad, soyad ya da öğrenci numarası sütunu ya da 9-11
6
+ // haneli bir numara görülürse tablo işlenmez; hoca bu sütunları Excel'de silip yalnız not sütunlarını yapıştırır ya
7
+ // da verinin tarayıcıdan çıkmadığı hesaplayıcıyı kullanır. Değerler hiçbir yere yazılmaz. Sütun başlıklarından ölçme aracı, soru numarası, tam puan ve ders çıktısı (ÇK)
4
8
  // eşlemesi sezilir; sezilemeyen bilgi tahmin edilmez, eksikler listesine girer. Sayılar yalnız tablodan okunur.
5
9
  import { z } from "zod";
6
10
  import { arac, KOD_KAYNAGI } from "./temel.js";
@@ -59,6 +63,13 @@ export function kimlikSutunlari(satirlar) {
59
63
  });
60
64
  return sonuc;
61
65
  }
66
+ /** Kimlik sütunu olmayan tablonun başına o1, o2 takma ad sütunu ekler. Başlıkta zaten takma ad varsa dokunmaz. */
67
+ export function takmaAdEkle(metin) {
68
+ const { satirlar } = tabloOku(metin);
69
+ if (satirlar.length < 2 || /^takma ad$/iu.test((satirlar[0]?.[0] ?? "").trim()))
70
+ return metin;
71
+ return satirlar.map((r, i) => [i === 0 ? "Takma ad" : `o${i}`, ...r].join("\t")).join("\n");
72
+ }
62
73
  /** Tablodan kimlik sütunlarını atar, başa o1, o2 takma adı koyar. Yalnız bellekte çalışır. */
63
74
  export function notTablosuAyikla(metin) {
64
75
  const { satirlar } = tabloOku(metin);
@@ -110,11 +121,23 @@ const puanOku = (s, ayirac) => {
110
121
  const n = Number(ayirac === "," ? t : t.replace(",", "."));
111
122
  return Number.isFinite(n) ? n : null;
112
123
  };
124
+ /** Kişisel veri görülünce verilen ileti (aynen). */
125
+ export const NOT_TABLOSU_KISISEL_ILETI = "Kişisel veriyi yapay zekâ sohbetine yapıştırmayın: Excel'de Ad, Soyad, Numara sütunlarını silip yalnız not sütunlarını yapıştırın ya da verinin tarayıcınızdan çıkmadığı hesaplayıcıyı kullanın: https://akredit.org/deneyin";
126
+ const UZUN_NUMARA = /(?<![\d.,])\d{9,11}(?![\d.,]\d)/u;
127
+ /** Tabloda kimlik sütunu (ad, soyad, numara...) ya da 9-11 haneli numara var mı? Varsa red iletisi döner. */
128
+ export function notTablosuKisiselVeri(metin) {
129
+ const { satirlar } = tabloOku(metin);
130
+ if (satirlar.length >= 2 && kimlikSutunlari(satirlar).length)
131
+ return NOT_TABLOSU_KISISEL_ILETI;
132
+ if (satirlar.some((r) => r.some((h) => UZUN_NUMARA.test(h.replace(/\s/gu, "")))))
133
+ return NOT_TABLOSU_KISISEL_ILETI;
134
+ return null;
135
+ }
113
136
  export const SUTUN_ESLEME_REHBERI = [
114
137
  "Excel'de ilk satır başlık olsun: her soru ya da ölçme için bir sütun.",
115
138
  "Başlık örnekleri: 'Vize S1 (ÇK1) /20', 'Vize S2 (ÇK2) /30', 'Final S1 ÇK3 /50', 'Ödev ÇK4 /100'.",
116
139
  "Başlıkta sınavın adı (Vize, Final, Ödev, Proje, Quiz), soru numarası (S1, Soru 2), ölçtüğü ders çıktısı (ÇK1 ya da Ö1) ve tam puanı (/20 ya da (20p)) olsun.",
117
- "Ad, soyad ve numara sütunlarını silmenize gerek yok: araç onları ayrıştırmadan önce atar ve o1, o2 diye takma ad verir. Yine de silerseniz daha iyi olur.",
140
+ "Ad, Soyad ve Numara sütunlarını yapıştırmadan önce Excel'de silin: yalnız not sütunları gelsin. Kimlik sütunu ya da numara görülürse tablo işlenmez. Öğrenciler o1, o2 diye takma ad alır.",
118
141
  "Toplam, Ortalama, Harf sütunları yok sayılır.",
119
142
  "Boş, '-' ya da 'G' (girmedi) hücreleri boş puan sayılır.",
120
143
  "Tabloyu Excel'de seçip Ctrl+C ile kopyalayın, sohbete Ctrl+V ile yapıştırın.",
@@ -122,25 +145,27 @@ export const SUTUN_ESLEME_REHBERI = [
122
145
  export const notTablosuAyristirAraci = arac({
123
146
  ad: "not_tablosu_ayristir",
124
147
  baslik: "Not tablosunu ayrıştır",
125
- aciklama: "Excel ya da CSV'den yapıştırılan not tablosunu ÇK/PÇ hesabına hazırlar. Ad, soyad ve numara sütunlarını ayrıştırmadan önce atar, öğrencilere o1, o2 takma adı verir. Başlıklardan sınav, soru, tam puan ve ders çıktısı eşlemesini sezer; eksikleri listeler.",
148
+ aciklama: "Excel ya da CSV'den yapıştırılan not tablosunu ÇK/PÇ hesabına hazırlar. Tabloda yalnız not sütunları olmalı: ad, soyad ya da numara sütunu ya da 9-11 haneli numara görülürse tablo işlenmez. Öğrencilere o1, o2 takma adı verir. Başlıklardan sınav, soru, tam puan ve ders çıktısı eşlemesini sezer; eksikleri listeler.",
126
149
  sonrakiAdim: "Sezilen eşlemeyi hocaya tablo olarak gösterin ve eksikleri tek tek seçenekle sorun (ör. S3 hangi çıktıyı ölçüyor? 1) Ö2 2) Ö3). Eksik kalmayınca ckPcHesaplaTaslagi'ni ck_pc_hesapla'ya verin.",
127
- kisiselVeriIpucu: "Kimlik sütunları atıldıktan sonra da kişisel veri kaldı. Ad ya da numara içeren başka sütun varsa silip yeniden yapıştırın.",
150
+ kisiselVeriIpucu: NOT_TABLOSU_KISISEL_ILETI,
128
151
  kisiselVeriKapsami: "tam",
152
+ onDenetim: (g) => {
153
+ const t = g?.tablo;
154
+ return typeof t === "string" ? notTablosuKisiselVeri(t) : null;
155
+ },
129
156
  onAyikla: (g) => {
157
+ // Kimlik sütunu yoksa tablo yalnız not sütunlarıdır; başa o1, o2 takma adı eklenir (sütun atılmaz).
130
158
  const t = g?.tablo;
131
159
  if (typeof t !== "string")
132
160
  return { girdi: g, atilan: [] };
133
- const r = notTablosuAyikla(t);
134
- return {
135
- girdi: { ...g, tablo: r.tablo },
136
- atilan: r.atilan,
137
- ileti: r.atilan.length
138
- ? `Öğrenci kimliği taşıyan sütunlar hesaplamadan önce atıldı, öğrencilere o1, o2 takma adı verildi; hiçbir yere yazılmadı: ${r.atilan.join(", ")}.`
139
- : undefined,
140
- };
161
+ return { girdi: { ...g, tablo: takmaAdEkle(t) }, atilan: [] };
141
162
  },
142
163
  girdi: z.object({
143
- tablo: z.string().min(5).max(2_000_000).describe("Excel ya da CSV'den yapıştırılan tablo; ilk satır başlık"),
164
+ tablo: z
165
+ .string()
166
+ .min(5)
167
+ .max(400_000)
168
+ .describe("Excel ya da CSV'den yapıştırılan, yalnız not sütunlarından oluşan tablo; ilk satır başlık"),
144
169
  tamPuanlar: z
145
170
  .record(z.string().max(80), z.number().positive().max(1000))
146
171
  .optional()
@@ -12,6 +12,8 @@ export declare const GIRIS_BILGISI_KURALI: string;
12
12
  /** Akıllı doldurma kısayolu: bütün menülerde aynı numara ve aynı anlam. */
13
13
  export declare const AKILLI_DOLDURMA_SECENEGI = "4) Hepsini ak\u0131ll\u0131ca doldur (\u00F6nerilen)";
14
14
  /** "4) Hepsini akıllıca doldur" seçildiğinde (ya da hoca "sen seç", "hepsi" derse) izlenecek yol. */
15
+ /** Bilinmesi mümkün olmayan ve resmî taahhüt olan bilgiler: sayfadan okunur, yoksa tek mesajda sorulur. */
16
+ export declare const TEYIT_SORULARI: string;
15
17
  export declare const AKILLI_DOLDURMA: string;
16
18
  /** Bütün istemlerde ortak çalışma biçimi. */
17
19
  export declare const ORTAK_KURALLAR: string;
@@ -21,5 +23,26 @@ export declare const AKREDIT_ODAKLARI: readonly {
21
23
  ad: string;
22
24
  }[];
23
25
  export declare const ACIK_ISTEMLER: readonly AcikIstem[];
26
+ /** Kısa kurallar: akredit_basla yanıtında (istem desteklemeyen istemciler için) yer alır. */
27
+ export declare const KISA_KURALLAR: string[];
28
+ /** akredit_basla aracının yanıtı: menü, kısa kurallar ve her seçimin akışı (istem desteklemeyen istemciler için). */
29
+ export declare function baslangic(): {
30
+ hocayaGoster: string;
31
+ menu: ({
32
+ no: "1" | "2" | "3" | "4" | "5";
33
+ ad: "Bologna ders paketini denetle ve boş alanları doldur" | "Ders öğrenme çıktısı yaz ya da iyileştir" | "ÇK ve PÇ sağlanma düzeyini hesapla" | "YÖKAK, MÜDEK, FEDEK, EPDAD ya da SABAK ölçüt ve kanıt rehberi" | "Öz değerlendirme metninin dil denetimi";
34
+ istem: "bologna_doldur" | "cikti_yaz" | "ck_pc_hesabi" | "olcut_rehberi" | "dil_denetimi";
35
+ } | {
36
+ no: string;
37
+ ad: string;
38
+ istem: null;
39
+ })[];
40
+ kisaKurallar: string[];
41
+ akislar: {
42
+ [k: string]: string;
43
+ };
44
+ ortakKurallar: string;
45
+ };
46
+ export declare const akreditBaslaAraci: import("./temel.ts").AcikArac<z.ZodObject<{}, z.core.$strip>>;
24
47
  /** SDK için bağımsız değişken şeması: hepsi isteğe bağlı, kısa metin. */
25
48
  export declare const istemSemasi: (i: AcikIstem) => Record<string, z.ZodOptional<z.ZodString>>;