ssampin-ai-bridge 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,2978 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../packages/mcp/dist/index.js
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // ../packages/mcp/dist/server.js
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { z } from "zod";
9
+
10
+ // ../packages/core/dist/paths.js
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ var VALID_FILENAME_RE = /^[A-Za-z0-9_-]+$/;
15
+ var APP_DIR_NAME = "\uC324\uD540";
16
+ var PathSecurityError = class extends Error {
17
+ name = "PathSecurityError";
18
+ };
19
+ function resolveDataDir(env = process.env) {
20
+ const override = env["SSAMPIN_DATA_DIR"];
21
+ if (override && override.trim().length > 0) {
22
+ return path.resolve(override);
23
+ }
24
+ const appData = env["APPDATA"];
25
+ if (appData && appData.trim().length > 0) {
26
+ return path.join(appData, APP_DIR_NAME, "data");
27
+ }
28
+ const home = os.homedir();
29
+ if (process.platform === "darwin") {
30
+ return path.join(home, "Library", "Application Support", APP_DIR_NAME, "data");
31
+ }
32
+ return path.join(home, ".config", APP_DIR_NAME, "data");
33
+ }
34
+ function isValidFilename(name) {
35
+ return VALID_FILENAME_RE.test(name);
36
+ }
37
+ function realpathOrResolve(p) {
38
+ const abs = path.resolve(p);
39
+ try {
40
+ return fs.realpathSync.native(abs);
41
+ } catch {
42
+ return abs;
43
+ }
44
+ }
45
+ function isInside(parent, child) {
46
+ const rel = path.relative(parent, child);
47
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
48
+ }
49
+ function resolveDataFile(dataDir, filename) {
50
+ if (!isValidFilename(filename)) {
51
+ throw new PathSecurityError(`\uC798\uBABB\uB41C \uD30C\uC77C\uBA85: ${JSON.stringify(filename)}`);
52
+ }
53
+ const baseReal = realpathOrResolve(dataDir);
54
+ const parentReal = realpathOrResolve(baseReal);
55
+ const finalPath = path.join(parentReal, `${filename}.json`);
56
+ if (!isInside(baseReal, finalPath)) {
57
+ throw new PathSecurityError(`\uB370\uC774\uD130 \uB514\uB809\uD1A0\uB9AC \uBC16 \uC811\uADFC \uCC28\uB2E8: ${finalPath}`);
58
+ }
59
+ return finalPath;
60
+ }
61
+ function backupPathFor(dataFilePath) {
62
+ return dataFilePath.replace(/\.json$/, ".backup.json");
63
+ }
64
+ function realDir(dataDir) {
65
+ return realpathOrResolve(dataDir);
66
+ }
67
+ function assertNoSymlinkEscape(baseRealDir, filePath) {
68
+ let isLink = false;
69
+ try {
70
+ isLink = fs.lstatSync(filePath).isSymbolicLink();
71
+ } catch {
72
+ return;
73
+ }
74
+ if (!isLink)
75
+ return;
76
+ let real;
77
+ try {
78
+ real = fs.realpathSync.native(filePath);
79
+ } catch {
80
+ throw new PathSecurityError(`symlink \uD574\uC11D \uC2E4\uD328: ${filePath}`);
81
+ }
82
+ if (!isInside(baseRealDir, real)) {
83
+ throw new PathSecurityError(`symlink \uD0C8\uCD9C \uCC28\uB2E8: ${filePath} -> ${real}`);
84
+ }
85
+ }
86
+ function bridgeStateDir(dataDir = resolveDataDir()) {
87
+ return path.join(dataDir, ".ssampin-aibridge");
88
+ }
89
+
90
+ // ../packages/core/dist/io.js
91
+ import fs2 from "node:fs";
92
+
93
+ // ../packages/core/dist/entities/student.js
94
+ function asString(v) {
95
+ return typeof v === "string" ? v : void 0;
96
+ }
97
+ function asNumber(v) {
98
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
99
+ }
100
+ function asBool(v) {
101
+ return typeof v === "boolean" ? v : void 0;
102
+ }
103
+ function setIf(target, key, value) {
104
+ if (value !== void 0)
105
+ target[key] = value;
106
+ }
107
+ function normalizeStudent(o) {
108
+ const s = {
109
+ id: String(o["id"]),
110
+ name: String(o["name"])
111
+ };
112
+ setIf(s, "studentNumber", asNumber(o["studentNumber"]));
113
+ setIf(s, "phone", asString(o["phone"]));
114
+ setIf(s, "parentPhone", asString(o["parentPhone"]));
115
+ setIf(s, "parentPhoneLabel", asString(o["parentPhoneLabel"]));
116
+ setIf(s, "parentPhone2", asString(o["parentPhone2"]));
117
+ setIf(s, "parentPhone2Label", asString(o["parentPhone2Label"]));
118
+ setIf(s, "isVacant", asBool(o["isVacant"]));
119
+ setIf(s, "birthDate", asString(o["birthDate"]));
120
+ setIf(s, "status", asString(o["status"]));
121
+ setIf(s, "statusNote", asString(o["statusNote"]));
122
+ setIf(s, "statusChangedAt", asString(o["statusChangedAt"]));
123
+ return s;
124
+ }
125
+ function parseStudents(raw) {
126
+ if (!Array.isArray(raw))
127
+ return [];
128
+ const out = [];
129
+ for (const item of raw) {
130
+ if (!item || typeof item !== "object")
131
+ continue;
132
+ const o = item;
133
+ if (typeof o["id"] !== "string" || typeof o["name"] !== "string")
134
+ continue;
135
+ out.push(normalizeStudent(o));
136
+ }
137
+ return out;
138
+ }
139
+
140
+ // ../packages/core/dist/entities/seating.js
141
+ function toSeatCell(v) {
142
+ return typeof v === "string" && v.length > 0 ? v : null;
143
+ }
144
+ function parseSeating(raw) {
145
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
146
+ return null;
147
+ const o = raw;
148
+ const rows = typeof o["rows"] === "number" ? o["rows"] : void 0;
149
+ const cols = typeof o["cols"] === "number" ? o["cols"] : void 0;
150
+ const rawSeats = o["seats"];
151
+ if (rows === void 0 || cols === void 0 || !Array.isArray(rawSeats))
152
+ return null;
153
+ const seats = rawSeats.map((row) => Array.isArray(row) ? row.map(toSeatCell) : []);
154
+ return { rows, cols, seats };
155
+ }
156
+
157
+ // ../packages/core/dist/entities/observation.js
158
+ function asString2(v) {
159
+ return typeof v === "string" ? v : void 0;
160
+ }
161
+ function asNumber2(v) {
162
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
163
+ }
164
+ function setIf2(target, key, value) {
165
+ if (value !== void 0)
166
+ target[key] = value;
167
+ }
168
+ function normalizeRecord(o) {
169
+ if (typeof o["id"] !== "string" || typeof o["studentId"] !== "string")
170
+ return null;
171
+ const visibility = o["visibility"] === "shared" ? "shared" : "private";
172
+ const tags = Array.isArray(o["tags"]) ? o["tags"].filter((t) => typeof t === "string") : [];
173
+ const rec = {
174
+ id: o["id"],
175
+ studentId: o["studentId"],
176
+ date: asString2(o["date"]) ?? "",
177
+ content: asString2(o["content"]) ?? "",
178
+ tags,
179
+ visibility
180
+ };
181
+ setIf2(rec, "classId", asString2(o["classId"]));
182
+ setIf2(rec, "authorId", asString2(o["authorId"]));
183
+ setIf2(rec, "createdAt", asNumber2(o["createdAt"]));
184
+ setIf2(rec, "updatedAt", asNumber2(o["updatedAt"]));
185
+ return rec;
186
+ }
187
+ function parseObservations(raw) {
188
+ const rawRecords = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw["records"]) ? raw["records"] : [];
189
+ const records = [];
190
+ for (const item of rawRecords) {
191
+ if (!item || typeof item !== "object")
192
+ continue;
193
+ const rec = normalizeRecord(item);
194
+ if (rec)
195
+ records.push(rec);
196
+ }
197
+ const customTags = raw && typeof raw === "object" && Array.isArray(raw["customTags"]) ? raw["customTags"].filter((t) => typeof t === "string") : void 0;
198
+ return customTags ? { records, customTags } : { records };
199
+ }
200
+
201
+ // ../packages/core/dist/entities/teachingClass.js
202
+ function studentKey(s) {
203
+ if (s.grade != null && s.classNum != null) {
204
+ return `${s.grade}-${s.classNum}-${s.number}`;
205
+ }
206
+ return String(s.number);
207
+ }
208
+ function asString3(v) {
209
+ return typeof v === "string" ? v : void 0;
210
+ }
211
+ function asNumber3(v) {
212
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
213
+ }
214
+ function asBool2(v) {
215
+ return typeof v === "boolean" ? v : void 0;
216
+ }
217
+ function setIf3(target, key, value) {
218
+ if (value !== void 0)
219
+ target[key] = value;
220
+ }
221
+ function normalizeStudent2(o) {
222
+ const number = asNumber3(o["number"]);
223
+ if (number === void 0)
224
+ return null;
225
+ if (typeof o["name"] !== "string")
226
+ return null;
227
+ const s = { number, name: o["name"] };
228
+ setIf3(s, "memo", asString3(o["memo"]));
229
+ setIf3(s, "grade", asNumber3(o["grade"]));
230
+ setIf3(s, "classNum", asNumber3(o["classNum"]));
231
+ setIf3(s, "isVacant", asBool2(o["isVacant"]));
232
+ setIf3(s, "status", asString3(o["status"]));
233
+ setIf3(s, "statusNote", asString3(o["statusNote"]));
234
+ setIf3(s, "statusChangedAt", asString3(o["statusChangedAt"]));
235
+ return s;
236
+ }
237
+ function normalizeClass(o) {
238
+ if (typeof o["id"] !== "string" || typeof o["name"] !== "string")
239
+ return null;
240
+ const rawStudents = Array.isArray(o["students"]) ? o["students"] : [];
241
+ const students = [];
242
+ const seenKeys = /* @__PURE__ */ new Set();
243
+ for (const item of rawStudents) {
244
+ if (!item || typeof item !== "object")
245
+ continue;
246
+ const st = normalizeStudent2(item);
247
+ if (!st)
248
+ continue;
249
+ const key = studentKey(st);
250
+ if (seenKeys.has(key))
251
+ continue;
252
+ seenKeys.add(key);
253
+ students.push(st);
254
+ }
255
+ const c = {
256
+ id: o["id"],
257
+ name: o["name"],
258
+ subject: asString3(o["subject"]) ?? "",
259
+ students
260
+ };
261
+ setIf3(c, "groupId", asString3(o["groupId"]));
262
+ setIf3(c, "order", asNumber3(o["order"]));
263
+ const sync = o["studentSyncMode"];
264
+ if (sync === "shared" || sync === "independent")
265
+ c["studentSyncMode"] = sync;
266
+ setIf3(c, "createdAt", asString3(o["createdAt"]));
267
+ setIf3(c, "updatedAt", asString3(o["updatedAt"]));
268
+ return c;
269
+ }
270
+ function parseTeachingClasses(raw) {
271
+ const rawClasses = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw["classes"]) ? raw["classes"] : [];
272
+ const classes = [];
273
+ for (const item of rawClasses) {
274
+ if (!item || typeof item !== "object")
275
+ continue;
276
+ const c = normalizeClass(item);
277
+ if (c)
278
+ classes.push(c);
279
+ }
280
+ return { classes };
281
+ }
282
+
283
+ // ../packages/core/dist/entities/rubric.js
284
+ function asString4(v) {
285
+ return typeof v === "string" ? v : void 0;
286
+ }
287
+ function asNumber4(v) {
288
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
289
+ }
290
+ function setIf4(target, key, value) {
291
+ if (value !== void 0)
292
+ target[key] = value;
293
+ }
294
+ function asStringRecord(v) {
295
+ if (!v || typeof v !== "object")
296
+ return {};
297
+ const out = {};
298
+ for (const [k, val] of Object.entries(v)) {
299
+ if (typeof val === "string")
300
+ out[k] = val;
301
+ }
302
+ return out;
303
+ }
304
+ function normalizeLevel(o) {
305
+ if (typeof o["id"] !== "string" || typeof o["name"] !== "string")
306
+ return null;
307
+ const lv = { id: o["id"], name: o["name"] };
308
+ setIf4(lv, "description", asString4(o["description"]));
309
+ return lv;
310
+ }
311
+ function normalizeCriterion(o) {
312
+ if (typeof o["id"] !== "string" || typeof o["name"] !== "string")
313
+ return null;
314
+ const levels = [];
315
+ if (Array.isArray(o["levels"])) {
316
+ for (const item of o["levels"]) {
317
+ if (item && typeof item === "object") {
318
+ const lv = normalizeLevel(item);
319
+ if (lv)
320
+ levels.push(lv);
321
+ }
322
+ }
323
+ }
324
+ return { id: o["id"], name: o["name"], order: asNumber4(o["order"]) ?? 0, levels };
325
+ }
326
+ function normalizeRubric(o) {
327
+ if (typeof o["id"] !== "string" || typeof o["classId"] !== "string" || typeof o["title"] !== "string") {
328
+ return null;
329
+ }
330
+ const criteria = [];
331
+ if (Array.isArray(o["criteria"])) {
332
+ for (const item of o["criteria"]) {
333
+ if (item && typeof item === "object") {
334
+ const c = normalizeCriterion(item);
335
+ if (c)
336
+ criteria.push(c);
337
+ }
338
+ }
339
+ }
340
+ const r = { id: o["id"], classId: o["classId"], title: o["title"], criteria };
341
+ setIf4(r, "description", asString4(o["description"]));
342
+ return r;
343
+ }
344
+ function normalizeGrading(o) {
345
+ if (typeof o["id"] !== "string" || typeof o["rubricId"] !== "string" || typeof o["classId"] !== "string" || typeof o["studentId"] !== "string") {
346
+ return null;
347
+ }
348
+ const status = o["status"] === "graded" || o["status"] === "absent" ? o["status"] : "partial";
349
+ const g = {
350
+ id: o["id"],
351
+ rubricId: o["rubricId"],
352
+ classId: o["classId"],
353
+ studentId: o["studentId"],
354
+ status,
355
+ marks: asStringRecord(o["marks"]),
356
+ criterionNotes: asStringRecord(o["criterionNotes"]),
357
+ gradedAt: asString4(o["gradedAt"]) ?? ""
358
+ };
359
+ setIf4(g, "overallFeedback", asString4(o["overallFeedback"]));
360
+ return g;
361
+ }
362
+ function parseRubrics(raw) {
363
+ const o = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
364
+ const rubrics = [];
365
+ if (Array.isArray(o["rubrics"])) {
366
+ for (const item of o["rubrics"]) {
367
+ if (item && typeof item === "object") {
368
+ const r = normalizeRubric(item);
369
+ if (r)
370
+ rubrics.push(r);
371
+ }
372
+ }
373
+ }
374
+ const gradings = [];
375
+ if (Array.isArray(o["gradings"])) {
376
+ for (const item of o["gradings"]) {
377
+ if (item && typeof item === "object") {
378
+ const g = normalizeGrading(item);
379
+ if (g)
380
+ gradings.push(g);
381
+ }
382
+ }
383
+ }
384
+ return { rubrics, gradings };
385
+ }
386
+
387
+ // ../packages/core/dist/entities/gradeAnalysis.js
388
+ function gradeStudentKey(ref) {
389
+ const name = ref.name.replace(/\s/g, "");
390
+ if (ref.grade != null && ref.classNum != null) {
391
+ return [ref.grade, ref.classNum, ref.number, name].join("-");
392
+ }
393
+ return [ref.number, name].join("-");
394
+ }
395
+ function asString5(v) {
396
+ return typeof v === "string" ? v : void 0;
397
+ }
398
+ function setIf5(target, key, value) {
399
+ if (value !== void 0)
400
+ target[key] = value;
401
+ }
402
+ function asAbsence(v) {
403
+ return v === "absent" || v === "recognized" || v === "exempt" || v === "none" ? v : void 0;
404
+ }
405
+ function normalizePlan(o) {
406
+ if (typeof o["id"] !== "string" || typeof o["teachingClassId"] !== "string" || typeof o["title"] !== "string") {
407
+ return null;
408
+ }
409
+ const kind = o["kind"] === "written-exam" ? "written-exam" : "performance";
410
+ const p = {
411
+ id: o["id"],
412
+ teachingClassId: o["teachingClassId"],
413
+ semester: asString5(o["semester"]) ?? "",
414
+ subject: asString5(o["subject"]) ?? "",
415
+ title: o["title"],
416
+ kind,
417
+ areaName: asString5(o["areaName"]) ?? ""
418
+ };
419
+ setIf5(p, "method", asString5(o["method"]));
420
+ return p;
421
+ }
422
+ function normalizeWritten(o) {
423
+ if (typeof o["id"] !== "string" || typeof o["assessmentId"] !== "string" || typeof o["studentKey"] !== "string") {
424
+ return null;
425
+ }
426
+ const w = {
427
+ id: o["id"],
428
+ assessmentId: o["assessmentId"],
429
+ studentKey: o["studentKey"],
430
+ scorePresent: typeof o["score"] === "number" && Number.isFinite(o["score"]),
431
+ confirmed: o["confirmed"] === true
432
+ };
433
+ setIf5(w, "absenceCode", asAbsence(o["absenceCode"]));
434
+ setIf5(w, "memo", asString5(o["memo"]));
435
+ return w;
436
+ }
437
+ function normalizePerformance(o) {
438
+ if (typeof o["id"] !== "string" || typeof o["assessmentId"] !== "string" || typeof o["studentKey"] !== "string") {
439
+ return null;
440
+ }
441
+ const p = {
442
+ id: o["id"],
443
+ assessmentId: o["assessmentId"],
444
+ studentKey: o["studentKey"],
445
+ scorePresent: typeof o["score"] === "number" && Number.isFinite(o["score"]),
446
+ confirmed: o["confirmed"] === true
447
+ };
448
+ setIf5(p, "rubricGradingId", asString5(o["rubricGradingId"]));
449
+ setIf5(p, "evidenceNote", asString5(o["evidenceNote"]));
450
+ setIf5(p, "memo", asString5(o["memo"]));
451
+ return p;
452
+ }
453
+ function normalizeSemester(o) {
454
+ if (typeof o["id"] !== "string" || typeof o["teachingClassId"] !== "string" || typeof o["studentKey"] !== "string") {
455
+ return null;
456
+ }
457
+ const s = {
458
+ id: o["id"],
459
+ teachingClassId: o["teachingClassId"],
460
+ semester: asString5(o["semester"]) ?? "",
461
+ studentKey: o["studentKey"],
462
+ confirmed: o["confirmed"] === true
463
+ };
464
+ setIf5(s, "achievementLevel", asString5(o["achievementLevel"]));
465
+ return s;
466
+ }
467
+ function collect(raw, key, fn) {
468
+ const o = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
469
+ const out = [];
470
+ if (Array.isArray(o[key])) {
471
+ for (const item of o[key]) {
472
+ if (item && typeof item === "object") {
473
+ const v = fn(item);
474
+ if (v)
475
+ out.push(v);
476
+ }
477
+ }
478
+ }
479
+ return out;
480
+ }
481
+ function parseGradeAnalysis(raw) {
482
+ return {
483
+ plans: collect(raw, "plans", normalizePlan),
484
+ writtenResults: collect(raw, "writtenResults", normalizeWritten),
485
+ performanceResults: collect(raw, "performanceResults", normalizePerformance),
486
+ semesterResults: collect(raw, "semesterResults", normalizeSemester)
487
+ };
488
+ }
489
+
490
+ // ../packages/core/dist/entities/meal.js
491
+ function asString6(v) {
492
+ return typeof v === "string" ? v : void 0;
493
+ }
494
+ function parseDishes(raw) {
495
+ if (!Array.isArray(raw))
496
+ return [];
497
+ const dishes = [];
498
+ for (const d of raw) {
499
+ if (!d || typeof d !== "object")
500
+ continue;
501
+ const o = d;
502
+ const name = asString6(o["name"]);
503
+ if (name === void 0 || name.length === 0)
504
+ continue;
505
+ const allergensRaw = o["allergens"];
506
+ const allergens = Array.isArray(allergensRaw) ? allergensRaw.filter((n) => typeof n === "number" && Number.isFinite(n)) : [];
507
+ dishes.push({ name, allergens });
508
+ }
509
+ return dishes;
510
+ }
511
+ function normalizeMeal(date, raw) {
512
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
513
+ return null;
514
+ const o = raw;
515
+ const ownDate = asString6(o["date"]);
516
+ const mealType = asString6(o["mealType"]) ?? "";
517
+ const dishes = parseDishes(o["dishes"]);
518
+ const calorie = asString6(o["calorie"]);
519
+ const entry = {
520
+ date: ownDate && ownDate.length > 0 ? ownDate : date,
521
+ mealType,
522
+ dishes
523
+ };
524
+ if (calorie !== void 0)
525
+ entry["calorie"] = calorie;
526
+ return entry;
527
+ }
528
+ function parseManualMeals(raw) {
529
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
530
+ return [];
531
+ const byDate = raw;
532
+ const out = [];
533
+ for (const date of Object.keys(byDate)) {
534
+ const arr = byDate[date];
535
+ if (!Array.isArray(arr))
536
+ continue;
537
+ for (const item of arr) {
538
+ const meal = normalizeMeal(date, item);
539
+ if (meal)
540
+ out.push(meal);
541
+ }
542
+ }
543
+ out.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
544
+ return out;
545
+ }
546
+
547
+ // ../packages/core/dist/entities/schoolEvent.js
548
+ function asString7(v) {
549
+ return typeof v === "string" ? v : void 0;
550
+ }
551
+ function asBool3(v) {
552
+ return typeof v === "boolean" ? v : void 0;
553
+ }
554
+ function setIf6(target, key, value) {
555
+ if (value !== void 0)
556
+ target[key] = value;
557
+ }
558
+ function normalizeEvent(raw) {
559
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
560
+ return null;
561
+ const o = raw;
562
+ if (o["isHidden"] === true)
563
+ return null;
564
+ const date = asString7(o["date"]);
565
+ const title = asString7(o["title"]);
566
+ if (date === void 0 || date.length === 0)
567
+ return null;
568
+ const rec = { date, title: title ?? "" };
569
+ setIf6(rec, "endDate", asString7(o["endDate"]));
570
+ setIf6(rec, "category", asString7(o["category"]));
571
+ setIf6(rec, "time", asString7(o["time"]));
572
+ setIf6(rec, "startTime", asString7(o["startTime"]));
573
+ setIf6(rec, "endTime", asString7(o["endTime"]));
574
+ setIf6(rec, "period", asString7(o["period"]));
575
+ setIf6(rec, "periodEnd", asString7(o["periodEnd"]));
576
+ setIf6(rec, "recurrence", asString7(o["recurrence"]));
577
+ setIf6(rec, "isDDay", asBool3(o["isDDay"]));
578
+ setIf6(rec, "description", asString7(o["description"]));
579
+ setIf6(rec, "location", asString7(o["location"]));
580
+ return rec;
581
+ }
582
+ function parseSchoolEvents(raw) {
583
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
584
+ return [];
585
+ const list = raw["events"];
586
+ if (!Array.isArray(list))
587
+ return [];
588
+ const out = [];
589
+ for (const item of list) {
590
+ const ev = normalizeEvent(item);
591
+ if (ev)
592
+ out.push(ev);
593
+ }
594
+ out.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
595
+ return out;
596
+ }
597
+
598
+ // ../packages/core/dist/entities/dday.js
599
+ function asString8(v) {
600
+ return typeof v === "string" ? v : void 0;
601
+ }
602
+ function asBool4(v) {
603
+ return typeof v === "boolean" ? v : void 0;
604
+ }
605
+ function setIf7(target, key, value) {
606
+ if (value !== void 0)
607
+ target[key] = value;
608
+ }
609
+ function normalizeDday(raw) {
610
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
611
+ return null;
612
+ const o = raw;
613
+ const date = asString8(o["targetDate"]);
614
+ if (date === void 0 || date.length === 0)
615
+ return null;
616
+ const rec = { date, title: asString8(o["title"]) ?? "" };
617
+ setIf7(rec, "emoji", asString8(o["emoji"]));
618
+ setIf7(rec, "color", asString8(o["color"]));
619
+ setIf7(rec, "pinned", asBool4(o["pinned"]));
620
+ return rec;
621
+ }
622
+ function parseDdays(raw) {
623
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
624
+ return [];
625
+ const list = raw["items"];
626
+ if (!Array.isArray(list))
627
+ return [];
628
+ const out = [];
629
+ for (const item of list) {
630
+ const d = normalizeDday(item);
631
+ if (d)
632
+ out.push(d);
633
+ }
634
+ out.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
635
+ return out;
636
+ }
637
+
638
+ // ../packages/core/dist/entities/todo.js
639
+ function asString9(v) {
640
+ return typeof v === "string" ? v : void 0;
641
+ }
642
+ function asBool5(v) {
643
+ return v === true;
644
+ }
645
+ function setIf8(target, key, value) {
646
+ if (value !== void 0)
647
+ target[key] = value;
648
+ }
649
+ function recurrenceType(v) {
650
+ if (!v || typeof v !== "object" || Array.isArray(v))
651
+ return void 0;
652
+ return asString9(v["type"]);
653
+ }
654
+ function normalizeTodo(raw) {
655
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
656
+ return null;
657
+ const o = raw;
658
+ const text = asString9(o["text"]);
659
+ if (text === void 0)
660
+ return null;
661
+ const rec = { text, completed: asBool5(o["completed"]) };
662
+ setIf8(rec, "dueDate", asString9(o["dueDate"]));
663
+ setIf8(rec, "startDate", asString9(o["startDate"]));
664
+ setIf8(rec, "time", asString9(o["time"]));
665
+ setIf8(rec, "priority", asString9(o["priority"]));
666
+ setIf8(rec, "category", asString9(o["category"]));
667
+ setIf8(rec, "status", asString9(o["status"]));
668
+ setIf8(rec, "recurrence", recurrenceType(o["recurrence"]));
669
+ setIf8(rec, "archivedAt", asString9(o["archivedAt"]));
670
+ setIf8(rec, "notes", asString9(o["notes"]));
671
+ const subTasks = o["subTasks"];
672
+ if (Array.isArray(subTasks)) {
673
+ rec["subTaskCount"] = subTasks.length;
674
+ rec["subTaskDone"] = subTasks.filter((s) => s && typeof s === "object" && s["completed"] === true).length;
675
+ }
676
+ return rec;
677
+ }
678
+ function parseTodos(raw) {
679
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
680
+ return [];
681
+ const list = raw["todos"];
682
+ if (!Array.isArray(list))
683
+ return [];
684
+ const out = [];
685
+ for (const item of list) {
686
+ const t = normalizeTodo(item);
687
+ if (t)
688
+ out.push(t);
689
+ }
690
+ out.sort((a, b) => {
691
+ const ad = a.dueDate ?? "\uFFFF";
692
+ const bd = b.dueDate ?? "\uFFFF";
693
+ return ad < bd ? -1 : ad > bd ? 1 : 0;
694
+ });
695
+ return out;
696
+ }
697
+ function effectiveTodoStatus(t) {
698
+ if (t.status === "todo" || t.status === "inProgress" || t.status === "done")
699
+ return t.status;
700
+ return t.completed ? "done" : "todo";
701
+ }
702
+
703
+ // ../packages/core/dist/entities/schedule.js
704
+ function asString10(v) {
705
+ return typeof v === "string" ? v : void 0;
706
+ }
707
+ function asNumber5(v) {
708
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
709
+ }
710
+ function setIf9(target, key, value) {
711
+ if (value !== void 0)
712
+ target[key] = value;
713
+ }
714
+ function flattenDayMap(raw, build) {
715
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
716
+ return [];
717
+ const byDay = raw;
718
+ const out = [];
719
+ for (const day of Object.keys(byDay)) {
720
+ const arr = byDay[day];
721
+ if (!Array.isArray(arr))
722
+ continue;
723
+ arr.forEach((slot, i) => {
724
+ if (!slot || typeof slot !== "object" || Array.isArray(slot))
725
+ return;
726
+ const rec = build(day, i, slot);
727
+ if (rec)
728
+ out.push(rec);
729
+ });
730
+ }
731
+ return out;
732
+ }
733
+ function parseClassSchedule(raw) {
734
+ return flattenDayMap(raw, (day, i, slot) => {
735
+ const subject = asString10(slot["subject"]) ?? "";
736
+ if (subject.length === 0)
737
+ return null;
738
+ const rec = { day, period: i + 1, subject };
739
+ const teacher = asString10(slot["teacher"]);
740
+ if (teacher !== void 0 && teacher.length > 0)
741
+ rec["teacher"] = teacher;
742
+ return rec;
743
+ });
744
+ }
745
+ function parseTeacherSchedule(raw) {
746
+ return flattenDayMap(raw, (day, i, slot) => {
747
+ const subject = asString10(slot["subject"]) ?? "";
748
+ if (subject.length === 0)
749
+ return null;
750
+ const rec = { day, period: i + 1, subject };
751
+ const classroom = asString10(slot["classroom"]);
752
+ if (classroom !== void 0 && classroom.length > 0)
753
+ rec["classroom"] = classroom;
754
+ return rec;
755
+ });
756
+ }
757
+ function parseTimetableOverrides(raw) {
758
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
759
+ return [];
760
+ const list = raw["overrides"];
761
+ if (!Array.isArray(list))
762
+ return [];
763
+ const out = [];
764
+ for (const item of list) {
765
+ if (!item || typeof item !== "object" || Array.isArray(item))
766
+ continue;
767
+ const o = item;
768
+ const date = asString10(o["date"]);
769
+ const period = asNumber5(o["period"]);
770
+ if (date === void 0 || period === void 0)
771
+ continue;
772
+ const rec = { date, period };
773
+ setIf9(rec, "subject", asString10(o["subject"]));
774
+ setIf9(rec, "classroom", asString10(o["classroom"]));
775
+ setIf9(rec, "kind", asString10(o["kind"]));
776
+ setIf9(rec, "scope", asString10(o["scope"]));
777
+ setIf9(rec, "substituteTeacher", asString10(o["substituteTeacher"]));
778
+ setIf9(rec, "reason", asString10(o["reason"]));
779
+ out.push(rec);
780
+ }
781
+ out.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : a.period - b.period);
782
+ return out;
783
+ }
784
+
785
+ // ../packages/core/dist/entities/note.js
786
+ function asString11(v) {
787
+ return typeof v === "string" ? v : void 0;
788
+ }
789
+ function asNumber6(v) {
790
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
791
+ }
792
+ function asArray(raw) {
793
+ return Array.isArray(raw) ? raw : [];
794
+ }
795
+ function parseNotebooks(raw) {
796
+ const out = [];
797
+ for (const item of asArray(raw)) {
798
+ if (!item || typeof item !== "object")
799
+ continue;
800
+ const o = item;
801
+ const id = asString11(o["id"]);
802
+ if (id === void 0)
803
+ continue;
804
+ out.push({ id, title: asString11(o["title"]) ?? "", archived: o["archived"] === true, order: asNumber6(o["order"]) });
805
+ }
806
+ return out;
807
+ }
808
+ function parseNoteSections(raw) {
809
+ const out = [];
810
+ for (const item of asArray(raw)) {
811
+ if (!item || typeof item !== "object")
812
+ continue;
813
+ const o = item;
814
+ const id = asString11(o["id"]);
815
+ const notebookId = asString11(o["notebookId"]);
816
+ if (id === void 0 || notebookId === void 0)
817
+ continue;
818
+ out.push({ id, notebookId, title: asString11(o["title"]) ?? "", order: asNumber6(o["order"]) });
819
+ }
820
+ return out;
821
+ }
822
+ function parseNotePages(raw) {
823
+ const out = [];
824
+ for (const item of asArray(raw)) {
825
+ if (!item || typeof item !== "object")
826
+ continue;
827
+ const o = item;
828
+ const id = asString11(o["id"]);
829
+ const sectionId = asString11(o["sectionId"]);
830
+ if (id === void 0 || sectionId === void 0)
831
+ continue;
832
+ const tags = asArray(o["tags"]).filter((t) => typeof t === "string");
833
+ const rec = {
834
+ id,
835
+ sectionId,
836
+ title: asString11(o["title"]) ?? "",
837
+ tags,
838
+ pinned: o["pinned"] === true
839
+ };
840
+ const updatedAt = asString11(o["updatedAt"]);
841
+ if (updatedAt !== void 0)
842
+ rec["updatedAt"] = updatedAt;
843
+ out.push(rec);
844
+ }
845
+ return out;
846
+ }
847
+
848
+ // ../packages/core/dist/entities/memo.js
849
+ function asString12(v) {
850
+ return typeof v === "string" ? v : void 0;
851
+ }
852
+ function parseMemos(raw) {
853
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
854
+ return [];
855
+ const list = raw["memos"];
856
+ if (!Array.isArray(list))
857
+ return [];
858
+ const out = [];
859
+ for (const item of list) {
860
+ if (!item || typeof item !== "object" || Array.isArray(item))
861
+ continue;
862
+ const o = item;
863
+ const text = asString12(o["content"]);
864
+ if (text === void 0)
865
+ continue;
866
+ const rec = { text, archived: o["archived"] === true };
867
+ const color = asString12(o["color"]);
868
+ if (color !== void 0)
869
+ rec["color"] = color;
870
+ out.push(rec);
871
+ }
872
+ return out;
873
+ }
874
+
875
+ // ../packages/core/dist/entities/bookmark.js
876
+ function asString13(v) {
877
+ return typeof v === "string" ? v : void 0;
878
+ }
879
+ function asNumber7(v) {
880
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
881
+ }
882
+ function parseBookmarks(raw) {
883
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
884
+ return { groups: [], bookmarks: [] };
885
+ const o = raw;
886
+ const groups = [];
887
+ for (const g of Array.isArray(o["groups"]) ? o["groups"] : []) {
888
+ if (!g || typeof g !== "object")
889
+ continue;
890
+ const go = g;
891
+ const id = asString13(go["id"]);
892
+ if (id === void 0)
893
+ continue;
894
+ groups.push({ id, name: asString13(go["name"]) ?? "", archived: go["archived"] === true, order: asNumber7(go["order"]) });
895
+ }
896
+ const bookmarks = [];
897
+ for (const b of Array.isArray(o["bookmarks"]) ? o["bookmarks"] : []) {
898
+ if (!b || typeof b !== "object")
899
+ continue;
900
+ const bo = b;
901
+ const url = asString13(bo["url"]);
902
+ const groupId = asString13(bo["groupId"]);
903
+ if (url === void 0 || url.length === 0 || groupId === void 0)
904
+ continue;
905
+ bookmarks.push({ groupId, name: asString13(bo["name"]) ?? "", url });
906
+ }
907
+ return { groups, bookmarks };
908
+ }
909
+
910
+ // ../packages/core/dist/io.js
911
+ function readRawJson(filename, dataDir = resolveDataDir()) {
912
+ const filePath = resolveDataFile(dataDir, filename);
913
+ const baseReal = realDir(dataDir);
914
+ try {
915
+ assertNoSymlinkEscape(baseReal, filePath);
916
+ if (fs2.existsSync(filePath)) {
917
+ const raw = fs2.readFileSync(filePath, "utf-8");
918
+ if (raw.trim().length < 2)
919
+ return null;
920
+ return JSON.parse(raw);
921
+ }
922
+ } catch (err) {
923
+ if (err instanceof Error && err.name === "PathSecurityError")
924
+ throw err;
925
+ const backup = backupPathFor(filePath);
926
+ try {
927
+ assertNoSymlinkEscape(baseReal, backup);
928
+ if (fs2.existsSync(backup)) {
929
+ const raw = fs2.readFileSync(backup, "utf-8");
930
+ return JSON.parse(raw);
931
+ }
932
+ } catch (backupErr) {
933
+ if (backupErr instanceof Error && backupErr.name === "PathSecurityError")
934
+ throw backupErr;
935
+ }
936
+ }
937
+ return null;
938
+ }
939
+ function readStudents(dataDir = resolveDataDir()) {
940
+ return parseStudents(readRawJson("students", dataDir));
941
+ }
942
+ function readSeating(dataDir = resolveDataDir()) {
943
+ return parseSeating(readRawJson("seating", dataDir));
944
+ }
945
+ function readTeachingClasses(dataDir = resolveDataDir()) {
946
+ return parseTeachingClasses(readRawJson("teaching-classes", dataDir));
947
+ }
948
+ function readRubrics(dataDir = resolveDataDir()) {
949
+ return parseRubrics(readRawJson("rubrics", dataDir));
950
+ }
951
+ function readGradeAnalysis(dataDir = resolveDataDir()) {
952
+ return parseGradeAnalysis(readRawJson("grade-analysis", dataDir));
953
+ }
954
+ function readManualMeals(dataDir = resolveDataDir()) {
955
+ return parseManualMeals(readRawJson("manual-meals", dataDir));
956
+ }
957
+ function readEvents(dataDir = resolveDataDir()) {
958
+ return parseSchoolEvents(readRawJson("events", dataDir));
959
+ }
960
+ function readDdays(dataDir = resolveDataDir()) {
961
+ return parseDdays(readRawJson("dday", dataDir));
962
+ }
963
+ function readTodos(dataDir = resolveDataDir()) {
964
+ return parseTodos(readRawJson("todos", dataDir));
965
+ }
966
+ function readClassSchedule(dataDir = resolveDataDir()) {
967
+ return parseClassSchedule(readRawJson("class-schedule", dataDir));
968
+ }
969
+ function readTeacherSchedule(dataDir = resolveDataDir()) {
970
+ return parseTeacherSchedule(readRawJson("teacher-schedule", dataDir));
971
+ }
972
+ function readTimetableOverrides(dataDir = resolveDataDir()) {
973
+ return parseTimetableOverrides(readRawJson("timetable-overrides", dataDir));
974
+ }
975
+ function readNotebooks(dataDir = resolveDataDir()) {
976
+ return parseNotebooks(readRawJson("note-notebooks", dataDir));
977
+ }
978
+ function readNoteSections(dataDir = resolveDataDir()) {
979
+ return parseNoteSections(readRawJson("note-sections", dataDir));
980
+ }
981
+ function readNotePages(dataDir = resolveDataDir()) {
982
+ return parseNotePages(readRawJson("note-pages-meta", dataDir));
983
+ }
984
+ function readMemos(dataDir = resolveDataDir()) {
985
+ return parseMemos(readRawJson("memos", dataDir));
986
+ }
987
+ function readBookmarks(dataDir = resolveDataDir()) {
988
+ return parseBookmarks(readRawJson("bookmarks", dataDir));
989
+ }
990
+
991
+ // ../packages/core/dist/identity.js
992
+ var TEACHING_PREFIX = "tc:";
993
+ var CLASS_PREFIX = "class:";
994
+ var OBSERVATION_PREFIX = "obs:";
995
+ function makeTeachingStudentIdentity(classId, studentKey2) {
996
+ return `${TEACHING_PREFIX}${classId}:${studentKey2}`;
997
+ }
998
+ function makeClassIdentity(classId) {
999
+ return `${CLASS_PREFIX}${classId}`;
1000
+ }
1001
+ function makeObservationIdentity(observationId) {
1002
+ return `${OBSERVATION_PREFIX}${observationId}`;
1003
+ }
1004
+ function parseIdentity(resolved) {
1005
+ if (resolved.startsWith(TEACHING_PREFIX)) {
1006
+ const rest = resolved.slice(TEACHING_PREFIX.length);
1007
+ const sep = rest.indexOf(":");
1008
+ if (sep > 0 && sep < rest.length - 1) {
1009
+ return { kind: "teaching", classId: rest.slice(0, sep), studentKey: rest.slice(sep + 1) };
1010
+ }
1011
+ return { kind: "homeroom", studentId: resolved };
1012
+ }
1013
+ if (resolved.startsWith(CLASS_PREFIX)) {
1014
+ const classId = resolved.slice(CLASS_PREFIX.length);
1015
+ if (classId.length > 0)
1016
+ return { kind: "class", classId };
1017
+ return { kind: "homeroom", studentId: resolved };
1018
+ }
1019
+ return { kind: "homeroom", studentId: resolved };
1020
+ }
1021
+
1022
+ // ../packages/core/dist/pii/pseudonymize.js
1023
+ import crypto from "node:crypto";
1024
+ import fs3 from "node:fs";
1025
+ import path2 from "node:path";
1026
+ var TOKEN_RE = /^(?:stu|tcs|cls|obs)_[0-9a-f]{12}$/;
1027
+ function defaultRandomToken(prefix) {
1028
+ return `${prefix}_` + crypto.randomBytes(6).toString("hex");
1029
+ }
1030
+ var TokenStore = class {
1031
+ map = { idToToken: {}, tokenToId: {} };
1032
+ filePath;
1033
+ randomToken;
1034
+ constructor(opts = {}) {
1035
+ const base = opts.dir ?? resolveDataDir();
1036
+ this.filePath = path2.join(bridgeStateDir(base), "tokenmap.json");
1037
+ this.randomToken = opts.randomToken ?? defaultRandomToken;
1038
+ this.load();
1039
+ }
1040
+ /**
1041
+ * 영속 맵 로드 + 무결성 검증.
1042
+ * 토큰 형식(불투명 난수)·양방향 일관성을 만족하는 쌍만 채택하고,
1043
+ * 손상·조작된 항목은 조용히 폐기한다("토큰은 불투명" 성질을 영속 상태에도 강제).
1044
+ */
1045
+ load() {
1046
+ let parsed;
1047
+ try {
1048
+ parsed = JSON.parse(fs3.readFileSync(this.filePath, "utf-8"));
1049
+ } catch {
1050
+ return;
1051
+ }
1052
+ const idToToken = parsed?.idToToken;
1053
+ const tokenToId = parsed?.tokenToId;
1054
+ if (!idToToken || !tokenToId || typeof idToToken !== "object" || typeof tokenToId !== "object") {
1055
+ return;
1056
+ }
1057
+ const clean = { idToToken: {}, tokenToId: {} };
1058
+ for (const [id, token] of Object.entries(idToToken)) {
1059
+ if (typeof token !== "string" || !TOKEN_RE.test(token))
1060
+ continue;
1061
+ if (tokenToId[token] !== id)
1062
+ continue;
1063
+ if (clean.tokenToId[token] !== void 0)
1064
+ continue;
1065
+ clean.idToToken[id] = token;
1066
+ clean.tokenToId[token] = id;
1067
+ }
1068
+ this.map = clean;
1069
+ }
1070
+ persist() {
1071
+ const dir = path2.dirname(this.filePath);
1072
+ fs3.mkdirSync(dir, { recursive: true });
1073
+ const tmp = `${this.filePath}.tmp`;
1074
+ fs3.writeFileSync(tmp, JSON.stringify(this.map, null, 2), "utf-8");
1075
+ fs3.renameSync(tmp, this.filePath);
1076
+ }
1077
+ /**
1078
+ * id(신원 문자열) → 토큰 (없으면 생성·영속).
1079
+ * opts.prefix 로 토큰 종류를 자기-기술한다(기본 'stu' = 담임 학생, 하위호환).
1080
+ * 이미 토큰이 있으면 prefix 와 무관하게 기존 토큰을 반환한다(멱등).
1081
+ */
1082
+ getToken(id, opts = {}) {
1083
+ const existing = this.map.idToToken[id];
1084
+ if (existing)
1085
+ return existing;
1086
+ const prefix = opts.prefix ?? "stu";
1087
+ let token = this.randomToken(prefix);
1088
+ while (this.map.tokenToId[token])
1089
+ token = this.randomToken(prefix);
1090
+ this.map.idToToken[id] = token;
1091
+ this.map.tokenToId[token] = id;
1092
+ this.persist();
1093
+ return token;
1094
+ }
1095
+ /** 토큰 → id (로컬 전용 복원, 외부 전송 금지) */
1096
+ resolveToken(token) {
1097
+ return this.map.tokenToId[token];
1098
+ }
1099
+ };
1100
+ function rosterFromTeachingClass(cls, store) {
1101
+ return cls.students.map((s) => ({
1102
+ token: store.getToken(makeTeachingStudentIdentity(cls.id, studentKey(s)), { prefix: "tcs" }),
1103
+ names: [s.name]
1104
+ }));
1105
+ }
1106
+
1107
+ // ../packages/core/dist/pii/patterns.js
1108
+ var SOURCES = {
1109
+ // 010-1234-5678 / 01012345678 / +82 10 1234 5678 / 010 123 4567
1110
+ phone: "(?:\\+?82[-\\s.]?)?0?1[016789][-\\s.]?\\d{3,4}[-\\s.]?\\d{4}",
1111
+ // 주민등록번호: 900315-1234567 / 900315 1234567
1112
+ rrn: "(?<!\\d)\\d{6}[-\\s]?[1-4]\\d{6}(?!\\d)",
1113
+ // 2010-03-15 / 2010.3.5 / 2010/12/31
1114
+ birthDash: "(?:19|20)\\d{2}[-./](?:0?[1-9]|1[0-2])[-./](?:0?[1-9]|[12]\\d|3[01])",
1115
+ // 2010년 3월 15일 / 10년 3월 15일
1116
+ birthKorean: "(?:19|20)?\\d{2}\\s*\uB144\\s*\\d{1,2}\\s*\uC6D4\\s*\\d{1,2}\\s*\uC77C",
1117
+ // 20100315 (8자리 압축)
1118
+ birthCompact: "(?<!\\d)(?:19|20)\\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01])(?!\\d)"
1119
+ };
1120
+ function globalPattern(name) {
1121
+ return new RegExp(SOURCES[name], "g");
1122
+ }
1123
+ function containsPii(value) {
1124
+ return Object.keys(SOURCES).some((name) => new RegExp(SOURCES[name]).test(value));
1125
+ }
1126
+ function containsContactPii(value) {
1127
+ return ["phone", "rrn"].some((name) => new RegExp(SOURCES[name]).test(value));
1128
+ }
1129
+ var MASK_ORDER = [
1130
+ { name: "rrn", label: "[\uC8FC\uBBFC\uBC88\uD638]", stat: "rrns" },
1131
+ { name: "phone", label: "[\uC804\uD654]", stat: "phones" },
1132
+ { name: "birthKorean", label: "[\uC0DD\uB144\uC6D4\uC77C]", stat: "birthDates" },
1133
+ { name: "birthDash", label: "[\uC0DD\uB144\uC6D4\uC77C]", stat: "birthDates" },
1134
+ { name: "birthCompact", label: "[\uC0DD\uB144\uC6D4\uC77C]", stat: "birthDates" }
1135
+ ];
1136
+
1137
+ // ../packages/core/dist/pii/deidentify.js
1138
+ function escapeRegExp(s) {
1139
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1140
+ }
1141
+ var NAME_SEP = "[\\s\\u00B7\\u2027\\u30FB\xB7]*";
1142
+ var INVISIBLE_RE = /[​‌‍­]/g;
1143
+ function deidentify(content, roster) {
1144
+ const stats = { names: 0, phones: 0, rrns: 0, birthDates: 0, studentNumbers: 0 };
1145
+ let text = content.normalize("NFC").replace(INVISIBLE_RE, "");
1146
+ const nameReplacements = [];
1147
+ for (const entry of roster) {
1148
+ for (const n of entry.names) {
1149
+ const needle = (n ?? "").normalize("NFC").replace(INVISIBLE_RE, "").trim();
1150
+ if (needle.length > 0)
1151
+ nameReplacements.push({ needle, token: entry.token });
1152
+ }
1153
+ }
1154
+ nameReplacements.sort((a, b) => b.needle.length - a.needle.length);
1155
+ for (const { needle, token } of nameReplacements) {
1156
+ const spaced = needle.split("").map(escapeRegExp).join(NAME_SEP);
1157
+ const re = new RegExp(spaced, "g");
1158
+ text = text.replace(re, () => {
1159
+ stats.names += 1;
1160
+ return token;
1161
+ });
1162
+ }
1163
+ for (const { name, label, stat } of MASK_ORDER) {
1164
+ text = text.replace(globalPattern(name), () => {
1165
+ stats[stat] += 1;
1166
+ return label;
1167
+ });
1168
+ }
1169
+ for (const entry of roster) {
1170
+ if (entry.studentNumber !== void 0) {
1171
+ const re = new RegExp(`(?<!\\d)${entry.studentNumber}(?!\\d)`, "g");
1172
+ text = text.replace(re, () => {
1173
+ stats.studentNumbers += 1;
1174
+ return "[\uD559\uBC88]";
1175
+ });
1176
+ }
1177
+ }
1178
+ return { text, stats };
1179
+ }
1180
+
1181
+ // ../packages/core/dist/audit.js
1182
+ import crypto2 from "node:crypto";
1183
+ import fs4 from "node:fs";
1184
+ import path3 from "node:path";
1185
+ var AuditValueError = class extends Error {
1186
+ name = "AuditValueError";
1187
+ };
1188
+ var ALLOWED_STAT_KEYS = /* @__PURE__ */ new Set([
1189
+ "names",
1190
+ "phones",
1191
+ "rrns",
1192
+ "birthDates",
1193
+ "studentNumbers",
1194
+ "observations",
1195
+ "students",
1196
+ "records",
1197
+ "items",
1198
+ "redactions"
1199
+ ]);
1200
+ function assertNoPii(field, value) {
1201
+ if (value !== void 0 && containsPii(value)) {
1202
+ throw new AuditValueError(`\uAC10\uC0AC\uB85C\uADF8 \uD544\uB4DC '${field}' \uC5D0 PII \uD615\uD0DC \uAC12\uC774 \uD3EC\uD568\uB428`);
1203
+ }
1204
+ }
1205
+ function assertNoContactPii(field, value) {
1206
+ if (value !== void 0 && containsContactPii(value)) {
1207
+ throw new AuditValueError(`\uAC10\uC0AC\uB85C\uADF8 \uD544\uB4DC '${field}' \uC5D0 \uC5F0\uB77D\uCC98/\uC8FC\uBBFC\uBC88\uD638 \uD615\uD0DC \uAC12\uC774 \uD3EC\uD568\uB428`);
1208
+ }
1209
+ }
1210
+ var AuditLog = class {
1211
+ filePath;
1212
+ saltPath;
1213
+ saltKey;
1214
+ constructor(dataDir = resolveDataDir()) {
1215
+ const stateDir = bridgeStateDir(dataDir);
1216
+ this.filePath = path3.join(stateDir, "audit.log.jsonl");
1217
+ this.saltPath = path3.join(stateDir, ".audit-salt");
1218
+ this.saltKey = this.loadOrCreateSalt();
1219
+ }
1220
+ get path() {
1221
+ return this.filePath;
1222
+ }
1223
+ /** per-install 랜덤 salt 로드/생성 (로그와 분리 보관) */
1224
+ loadOrCreateSalt() {
1225
+ try {
1226
+ const hex = fs4.readFileSync(this.saltPath, "utf-8").trim();
1227
+ if (/^[0-9a-f]{64}$/.test(hex))
1228
+ return Buffer.from(hex, "hex");
1229
+ } catch {
1230
+ }
1231
+ const key = crypto2.randomBytes(32);
1232
+ fs4.mkdirSync(path3.dirname(this.saltPath), { recursive: true });
1233
+ const tmp = `${this.saltPath}.tmp`;
1234
+ fs4.writeFileSync(tmp, key.toString("hex"), "utf-8");
1235
+ fs4.renameSync(tmp, this.saltPath);
1236
+ return key;
1237
+ }
1238
+ /**
1239
+ * id → keyed HMAC 해시 (앞 16자).
1240
+ * 무염 해시와 달리, salt 없이 로그만 유출돼도 저엔트로피 id(학번·전화성)를
1241
+ * 사전대입으로 복원할 수 없다.
1242
+ */
1243
+ hashRecordId(id) {
1244
+ return crypto2.createHmac("sha256", this.saltKey).update(id).digest("hex").slice(0, 16);
1245
+ }
1246
+ append(input) {
1247
+ assertNoPii("tool", input.tool);
1248
+ assertNoPii("consentId", input.consentId);
1249
+ assertNoPii("destination", input.destination);
1250
+ assertNoContactPii("period", input.period);
1251
+ assertNoContactPii("rulePackVersion", input.rulePackVersion);
1252
+ assertNoPii("validatorResult", input.validatorResult);
1253
+ if (input.redactionStats) {
1254
+ for (const [k, v] of Object.entries(input.redactionStats)) {
1255
+ if (!ALLOWED_STAT_KEYS.has(k)) {
1256
+ throw new AuditValueError("redactionStats \uC5D0 \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uC740 \uD0A4\uAC00 \uD3EC\uD568\uB428(allowlist \uC678)");
1257
+ }
1258
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
1259
+ throw new AuditValueError(`redactionStats['${k}'] \uB294 0 \uC774\uC0C1 \uC815\uC218\uC5EC\uC57C \uD568`);
1260
+ }
1261
+ }
1262
+ }
1263
+ const entry = {
1264
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1265
+ tool: input.tool
1266
+ };
1267
+ if (input.consentId !== void 0)
1268
+ entry["consentId"] = input.consentId;
1269
+ if (input.destination !== void 0)
1270
+ entry["destination"] = input.destination;
1271
+ if (input.period !== void 0)
1272
+ entry["period"] = input.period;
1273
+ if (input.recordIds)
1274
+ entry["recordHashes"] = input.recordIds.map((id) => this.hashRecordId(id));
1275
+ if (input.redactionStats)
1276
+ entry["redactionStats"] = input.redactionStats;
1277
+ if (input.rulePackVersion !== void 0)
1278
+ entry["rulePackVersion"] = input.rulePackVersion;
1279
+ if (input.validatorResult !== void 0)
1280
+ entry["validatorResult"] = input.validatorResult;
1281
+ const full = entry;
1282
+ fs4.mkdirSync(path3.dirname(this.filePath), { recursive: true });
1283
+ fs4.appendFileSync(this.filePath, `${JSON.stringify(full)}
1284
+ `, "utf-8");
1285
+ return full;
1286
+ }
1287
+ readAll() {
1288
+ try {
1289
+ const raw = fs4.readFileSync(this.filePath, "utf-8");
1290
+ return raw.split("\n").filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
1291
+ } catch {
1292
+ return [];
1293
+ }
1294
+ }
1295
+ };
1296
+
1297
+ // ../packages/core/dist/write.js
1298
+ import crypto3 from "node:crypto";
1299
+ import fs5 from "node:fs";
1300
+ import path4 from "node:path";
1301
+ var LOCK_ACQUIRE_TIMEOUT_MS = 5e3;
1302
+ var MAX_CONTENT = 500;
1303
+ function isAlive(pid) {
1304
+ if (!Number.isInteger(pid) || pid <= 0)
1305
+ return false;
1306
+ try {
1307
+ process.kill(pid, 0);
1308
+ return true;
1309
+ } catch (e) {
1310
+ return e.code === "EPERM";
1311
+ }
1312
+ }
1313
+ var WriteDisabledError = class extends Error {
1314
+ name = "WriteDisabledError";
1315
+ };
1316
+ var WriteValidationError = class extends Error {
1317
+ name = "WriteValidationError";
1318
+ };
1319
+ var WriteConflictError = class extends Error {
1320
+ name = "WriteConflictError";
1321
+ };
1322
+ var WriteLockError = class extends Error {
1323
+ name = "WriteLockError";
1324
+ };
1325
+ function isWriteEnabled(env = process.env) {
1326
+ return env["SSAMPIN_BRIDGE_ALLOW_WRITE"] === "1";
1327
+ }
1328
+ function assertWriteEnabled(env = process.env) {
1329
+ if (!isWriteEnabled(env)) {
1330
+ throw new WriteDisabledError("\uC4F0\uAE30\uAC00 \uBE44\uD65C\uC131\uD654\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4. SSAMPIN_BRIDGE_ALLOW_WRITE=1 \uD658\uACBD\uBCC0\uC218\uB85C \uBA85\uC2DC\uC801\uC73C\uB85C \uD65C\uC131\uD654\uD558\uC138\uC694(\uC324\uD540\uC744 \uB2EB\uC740 \uC0C1\uD0DC \uAD8C\uC7A5).");
1331
+ }
1332
+ }
1333
+ function sleep(ms) {
1334
+ return new Promise((r) => setTimeout(r, ms));
1335
+ }
1336
+ function setIf10(target, key, value) {
1337
+ if (value !== void 0)
1338
+ target[key] = value;
1339
+ }
1340
+ function validate(input) {
1341
+ if (typeof input.studentId !== "string" || input.studentId.trim().length === 0) {
1342
+ throw new WriteValidationError("studentId \uAC00 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.");
1343
+ }
1344
+ const content = input.content ?? "";
1345
+ if (typeof content !== "string" || content.trim().length === 0) {
1346
+ throw new WriteValidationError("content \uAC00 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.");
1347
+ }
1348
+ if (content.length > MAX_CONTENT) {
1349
+ throw new WriteValidationError(`content \uB294 \uCD5C\uB300 ${MAX_CONTENT}\uC790\uC785\uB2C8\uB2E4(\uD604\uC7AC ${content.length}).`);
1350
+ }
1351
+ if (input.date !== void 0 && !/^\d{4}-\d{2}-\d{2}$/.test(input.date)) {
1352
+ throw new WriteValidationError("date \uB294 YYYY-MM-DD \uD615\uC2DD\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.");
1353
+ }
1354
+ if (input.tags !== void 0 && !Array.isArray(input.tags)) {
1355
+ throw new WriteValidationError("tags \uB294 \uBB38\uC790\uC5F4 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.");
1356
+ }
1357
+ }
1358
+ function todayIso() {
1359
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1360
+ }
1361
+ function buildRecord(input) {
1362
+ const now = Date.now();
1363
+ const rec = {
1364
+ id: `o_${now}_${crypto3.randomBytes(4).toString("hex")}`,
1365
+ studentId: input.studentId,
1366
+ date: input.date ?? todayIso(),
1367
+ content: input.content.trim(),
1368
+ tags: input.tags ? input.tags.filter((t) => typeof t === "string") : [],
1369
+ visibility: input.visibility === "shared" ? "shared" : "private",
1370
+ createdAt: now,
1371
+ updatedAt: now
1372
+ };
1373
+ setIf10(rec, "classId", input.classId);
1374
+ return rec;
1375
+ }
1376
+ function idemPath(dataDir) {
1377
+ return path4.join(bridgeStateDir(dataDir), "idempotency.json");
1378
+ }
1379
+ function loadIdem(dataDir) {
1380
+ try {
1381
+ return JSON.parse(fs5.readFileSync(idemPath(dataDir), "utf-8"));
1382
+ } catch {
1383
+ return {};
1384
+ }
1385
+ }
1386
+ function saveIdem(dataDir, map) {
1387
+ const p = idemPath(dataDir);
1388
+ fs5.mkdirSync(path4.dirname(p), { recursive: true });
1389
+ const tmp = `${p}.tmp`;
1390
+ fs5.writeFileSync(tmp, JSON.stringify(map), "utf-8");
1391
+ fs5.renameSync(tmp, p);
1392
+ }
1393
+ function tryReclaimDeadLock(lockPath, nonce) {
1394
+ const claim = `${lockPath}.reclaim-${nonce}`;
1395
+ try {
1396
+ fs5.renameSync(lockPath, claim);
1397
+ } catch {
1398
+ return;
1399
+ }
1400
+ let dead = false;
1401
+ try {
1402
+ const cur = JSON.parse(fs5.readFileSync(claim, "utf-8"));
1403
+ dead = typeof cur.pid === "number" ? !isAlive(cur.pid) : true;
1404
+ } catch {
1405
+ dead = true;
1406
+ }
1407
+ if (dead) {
1408
+ try {
1409
+ fs5.unlinkSync(claim);
1410
+ } catch {
1411
+ }
1412
+ } else {
1413
+ try {
1414
+ fs5.linkSync(claim, lockPath);
1415
+ } catch {
1416
+ }
1417
+ try {
1418
+ fs5.unlinkSync(claim);
1419
+ } catch {
1420
+ }
1421
+ }
1422
+ }
1423
+ async function withLock(dataDir, fn) {
1424
+ const lockPath = path4.join(bridgeStateDir(dataDir), "write.lock");
1425
+ fs5.mkdirSync(path4.dirname(lockPath), { recursive: true });
1426
+ const nonce = crypto3.randomBytes(8).toString("hex");
1427
+ const start = Date.now();
1428
+ for (; ; ) {
1429
+ const tmp = `${lockPath}.acq-${nonce}`;
1430
+ try {
1431
+ fs5.writeFileSync(tmp, JSON.stringify({ pid: process.pid, nonce, ts: Date.now() }), "utf-8");
1432
+ fs5.linkSync(tmp, lockPath);
1433
+ fs5.unlinkSync(tmp);
1434
+ break;
1435
+ } catch {
1436
+ try {
1437
+ fs5.unlinkSync(tmp);
1438
+ } catch {
1439
+ }
1440
+ let ownerAlive = true;
1441
+ try {
1442
+ const cur = JSON.parse(fs5.readFileSync(lockPath, "utf-8"));
1443
+ ownerAlive = typeof cur.pid === "number" ? isAlive(cur.pid) : false;
1444
+ } catch {
1445
+ ownerAlive = false;
1446
+ }
1447
+ if (!ownerAlive) {
1448
+ tryReclaimDeadLock(lockPath, nonce);
1449
+ continue;
1450
+ }
1451
+ if (Date.now() - start > LOCK_ACQUIRE_TIMEOUT_MS) {
1452
+ throw new WriteLockError("\uC4F0\uAE30 \uB77D \uD68D\uB4DD \uC2DC\uAC04 \uCD08\uACFC(\uB2E4\uB978 \uC4F0\uAE30 \uC9C4\uD589 \uC911).");
1453
+ }
1454
+ await sleep(15);
1455
+ }
1456
+ }
1457
+ try {
1458
+ return fn();
1459
+ } finally {
1460
+ try {
1461
+ const cur = JSON.parse(fs5.readFileSync(lockPath, "utf-8"));
1462
+ if (cur.nonce === nonce)
1463
+ fs5.unlinkSync(lockPath);
1464
+ } catch {
1465
+ }
1466
+ }
1467
+ }
1468
+ async function appendObservation(dataDir, input) {
1469
+ assertWriteEnabled();
1470
+ validate(input);
1471
+ const baseReal = realDir(dataDir);
1472
+ const file = resolveDataFile(dataDir, "observations");
1473
+ assertNoSymlinkEscape(baseReal, file);
1474
+ return withLock(dataDir, () => {
1475
+ const baseRaw = fs5.existsSync(file) ? fs5.readFileSync(file, "utf-8") : "";
1476
+ const data = parseObservations(baseRaw.length > 0 ? JSON.parse(baseRaw) : { records: [] });
1477
+ if (input.clientKey) {
1478
+ const idem = loadIdem(dataDir);
1479
+ const existingId = idem[input.clientKey];
1480
+ if (existingId) {
1481
+ const found = data.records.find((r) => r.id === existingId);
1482
+ if (found)
1483
+ return found;
1484
+ }
1485
+ }
1486
+ const record = buildRecord(input);
1487
+ const nextData = { records: [...data.records, record] };
1488
+ setIf10(nextData, "customTags", data.customTags);
1489
+ const nowRaw = fs5.existsSync(file) ? fs5.readFileSync(file, "utf-8") : "";
1490
+ if (nowRaw !== baseRaw) {
1491
+ throw new WriteConflictError("\uC4F0\uAE30 \uB3C4\uC911 \uB370\uC774\uD130\uAC00 \uBCC0\uACBD\uB418\uC5C8\uC2B5\uB2C8\uB2E4(\uC324\uD540 \uC2E4\uD589 \uC911\uC77C \uC218 \uC788\uC74C). \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.");
1492
+ }
1493
+ if (baseRaw.length > 10) {
1494
+ fs5.writeFileSync(backupPathFor(file), baseRaw, "utf-8");
1495
+ }
1496
+ const tmp = `${file}.tmp`;
1497
+ fs5.writeFileSync(tmp, JSON.stringify(nextData, null, 2), "utf-8");
1498
+ fs5.renameSync(tmp, file);
1499
+ if (input.clientKey) {
1500
+ const idem = loadIdem(dataDir);
1501
+ idem[input.clientKey] = record.id;
1502
+ saveIdem(dataDir, idem);
1503
+ }
1504
+ return record;
1505
+ });
1506
+ }
1507
+
1508
+ // ../packages/core/dist/grounding.js
1509
+ function isContentExposureEnabled(env = process.env) {
1510
+ return env["SSAMPIN_BRIDGE_ALLOW_CONTENT"] === "1";
1511
+ }
1512
+ var ContentExposureDisabledError = class extends Error {
1513
+ name = "ContentExposureDisabledError";
1514
+ };
1515
+ function getStudentObservations(dataDir, studentId, query = {}) {
1516
+ const data = parseObservations(readRawJson("observations", dataDir));
1517
+ return data.records.filter((r) => r.studentId === studentId).filter((r) => query.from ? r.date >= query.from : true).filter((r) => query.to ? r.date <= query.to : true);
1518
+ }
1519
+ function getTeachingObservations(dataDir, classId, studentKey2, query = {}) {
1520
+ const data = parseObservations(readRawJson("observations", dataDir));
1521
+ return data.records.filter((r) => r.classId === classId && r.studentId === studentKey2).filter((r) => query.from ? r.date >= query.from : true).filter((r) => query.to ? r.date <= query.to : true);
1522
+ }
1523
+ var COVERAGE_THRESHOLD = 0.18;
1524
+ var UNCOVERED_RUN_MAX = 4;
1525
+ var DISCLAIMER = '\uC5B4\uD718 \uC2A4\uD06C\uB9B0\uC77C \uBFD0 \uC758\uBBF8 \uAC80\uC99D\xB7\uAE30\uC7AC \uC801\uD569\uC131\xB7\uC0AC\uC2E4 \uD655\uC778\uC774 \uC544\uB2D9\uB2C8\uB2E4. flags \uAC00 \uC5C6\uC5B4\uB3C4 "\uAE30\uC7AC \uAC00\uB2A5"\uC774 \uC544\uB2C8\uBA70, \uC9E7\uC740 \uB0A0\uC870(\uC218\uC0C1\xB7\uB300\uC0C1\xB7\uC9C4\uB2E8\uBA85 \uB4F1)\uB294 \uC790\uB3D9\uC73C\uB85C \uBABB \uC7A1\uC744 \uC218 \uC788\uC73C\uB2C8 \uBAA8\uB4E0 \uBB38\uC7A5\uC744 \uAD50\uC0AC\uAC00 \uC9C1\uC811 \uC0AC\uC2E4 \uD655\uC778\uD574\uC57C \uD569\uB2C8\uB2E4. \uAE08\uC9C0\uD45C\uD604\xB7\uAE30\uC7AC\uC694\uB839\xB7\uC791\uC131\uBC94\uC704\uB3C4 \uBCC4\uB3C4\uB85C \uD655\uC778\uD558\uC138\uC694.';
1526
+ var LEVEL_LABELS = {
1527
+ elementary: "\uCD08\uB4F1\uD559\uAD50",
1528
+ middle: "\uC911\uD559\uAD50",
1529
+ high: "\uACE0\uB4F1\uD559\uAD50"
1530
+ };
1531
+ var REF_HUNRYUNG = {
1532
+ title: "\uD559\uAD50\uC0DD\uD65C\uAE30\uB85D \uC791\uC131 \uBC0F \uAD00\uB9AC\uC9C0\uCE68(\uAD50\uC721\uBD80\uD6C8\uB839 \uC81C555\uD638, \uC2DC\uD589 2026-03-01)",
1533
+ url: "https://star.moe.go.kr/web/contents/m20103.do"
1534
+ };
1535
+ var REF_REVISION_2026 = {
1536
+ title: "2026\uD559\uB144\uB3C4 \uD559\uAD50\uC0DD\uD65C\uAE30\uB85D\uBD80 \uAE30\uC7AC\uC694\uB839 \uC8FC\uC694 \uAC1C\uC815\uC0AC\uD56D(\uAD50\uC721\uBD80\xB7KERIS, 2026-02-19)",
1537
+ url: "https://star.moe.go.kr/web/contents/m40100.do"
1538
+ };
1539
+ var REF_PORTAL = {
1540
+ title: "\uD559\uAD50\uC0DD\uD65C\uAE30\uB85D\uBD80 \uAE30\uC7AC\uC694\uB839 \uC790\uB8CC\uC2E4(\uD559\uAD50\uC0DD\uD65C\uAE30\uB85D\uBD80 \uC885\uD569\uC9C0\uC6D0\uD3EC\uD138)",
1541
+ url: "https://star.moe.go.kr/web/contents/m21100.do"
1542
+ };
1543
+ var REFERENCES = [REF_HUNRYUNG, REF_REVISION_2026, REF_PORTAL];
1544
+ var REF_HUNRYUNG_2025 = {
1545
+ title: "\uD559\uAD50\uC0DD\uD65C\uAE30\uB85D \uC791\uC131 \uBC0F \uAD00\uB9AC\uC9C0\uCE68(\uAD50\uC721\uBD80\uD6C8\uB839 \uC81C504\uD638, \uC2DC\uD589 2025-03-01)",
1546
+ url: "https://star.moe.go.kr/web/contents/m20103.do"
1547
+ };
1548
+ var REF_REVISION_2025 = {
1549
+ title: "2025\uD559\uB144\uB3C4 \uD559\uAD50\uC0DD\uD65C\uAE30\uB85D\uBD80 \uAE30\uC7AC\uC694\uB839 \uC8FC\uC694 \uAC1C\uC815\uC0AC\uD56D(\uAD50\uC721\uBD80\xB7KERIS)",
1550
+ url: "https://star.moe.go.kr/web/contents/m40100.do"
1551
+ };
1552
+ var REFERENCES_2025 = [REF_HUNRYUNG_2025, REF_REVISION_2025, REF_PORTAL];
1553
+ var DISCLAIMER_GUIDE = '\uC5F0\uB3C4\xB7\uD559\uAD50\uAE09\uBCC4 \uBCF4\uC218\uC801 \uC694\uC57D \uCC38\uC870\uC6A9\uC785\uB2C8\uB2E4. flags\xB7\uC6D0\uCE59 \uCDA9\uC871\uC774 "\uAE30\uC7AC \uAC00\uB2A5"\uC744 \uB73B\uD558\uC9C0 \uC54A\uC73C\uBA70, \uD559\uAD50\uAE09\xB7\uC5F0\uB3C4\uBCC4 \uCD5C\uC2E0 \uAE30\uC7AC\uC694\uB839 \uC6D0\uBB38\uACFC \uAE08\uC9C0\uD45C\uD604\xB7\uC791\uC131\uBC94\uC704\uB97C \uBC18\uB4DC\uC2DC \uD655\uC778\uD558\uACE0 \uAD50\uC0AC\uAC00 \uCD5C\uC885 \uAC80\uD1A0\uD558\uC138\uC694.';
1554
+ function principle(text, ref) {
1555
+ return { text, source: ref.title, url: ref.url };
1556
+ }
1557
+ var COMMON_PRINCIPLES = [
1558
+ principle("\uAD50\uC0AC\uAC00 \uC9C1\uC811 \uAD00\uCC30\xB7\uD3C9\uAC00\uD55C \uC0AC\uC2E4\uC744 \uADFC\uAC70\uB85C \uC785\uB825\uD558\uACE0, \uCD94\uCE21\xB7\uACFC\uC7A5\xB7\uBBF8\uAC80\uC99D \uB0B4\uC6A9\uC740 \uAE30\uC7AC\uD558\uC9C0 \uC54A\uB294\uB2E4.", REF_HUNRYUNG),
1559
+ principle("\uC0DD\uC131\uD615 AI\uAC00 \uB9CC\uB4E0 \uBB38\uC7A5\uC744 \uADF8\uB300\uB85C \uC62E\uACA8 \uC801\uC9C0 \uC54A\uB294\uB2E4(2026 \uAC1C\uC815). \uAE30\uB85D \uC8FC\uCCB4\uB294 \uAD50\uC0AC\uC774\uBA70 \uBAA8\uB4E0 \uBB38\uC7A5\uC740 \uC218\uC5C5 \uC911 \uC2E4\uC81C \uAD00\uCC30\uC5D0 \uADFC\uAC70\uD574\uC57C \uD55C\uB2E4.", REF_REVISION_2026),
1560
+ principle("\uB2E4\uC74C\uC740 \uC785\uB825\uD560 \uC218 \uC5C6\uB2E4: \uAD50\uB0B4\uC678 \uC778\uC99D\uC2DC\uD5D8 \uC131\uC801, \uBAA8\uC758\uACE0\uC0AC\xB7\uC804\uAD6D\uC5F0\uD569\uD559\uB825\uD3C9\uAC00 \uC131\uC801, \uB17C\uBB38 \uD22C\uACE0\xB7\uB4F1\uC7AC, \uB3C4\uC11C \uCD9C\uAC04, \uC9C0\uC2DD\uC7AC\uC0B0\uAD8C(\uD2B9\uD5C8\xB7\uC2E4\uC6A9\uC2E0\uC548 \uB4F1) \uCD9C\uC6D0\xB7\uB4F1\uB85D, \uC5B4\uD559\uC5F0\uC218 \uB4F1 \uD574\uC678 \uD65C\uB3D9\uC2E4\uC801, \uBD80\uBAA8\xB7\uCE5C\uC778\uCC99\uC758 \uC0AC\uD68C\xB7\uACBD\uC81C\uC801 \uC9C0\uC704 \uC554\uC2DC.", REF_HUNRYUNG),
1561
+ principle("\uAD6C\uCCB4\uC801 \uC0AC\uB840 \uC911\uC2EC\uC73C\uB85C \uD559\uC0DD\uC758 \uC131\uC7A5\xB7\uBCC0\uD654\uB97C \uC11C\uC220\uD558\uACE0, \uB2E8\uC21C \uC0AC\uC2E4 \uB098\uC5F4\uC774\uB098 \uB2E8\uC815\uC801 \uD3C9\uAC00\uB294 \uD53C\uD55C\uB2E4.", REF_PORTAL),
1562
+ principle('"\uADFC\uAC70 \uC874\uC7AC"\uC640 "\uAE30\uC7AC \uAC00\uB2A5"\uC740 \uB2E4\uB974\uB2E4 \u2014 \uD559\uAD50\uAE09\xB7\uC5F0\uB3C4\uBCC4 \uAE30\uC7AC\uC694\uB839\uACFC \uC791\uC131 \uBC94\uC704\xB7\uAE08\uC9C0\uD45C\uD604\uC744 \uD568\uAED8 \uD655\uC778\uD55C\uB2E4.', REF_PORTAL),
1563
+ principle("\uCD5C\uC885 \uAE30\uC7AC \uC804 \uAD50\uC0AC\uAC00 \uADDC\uC815\uACFC \uC0AC\uC2E4\uC744 \uBC18\uB4DC\uC2DC \uAC80\uD1A0\uD55C\uB2E4. \uB9C8\uAC10 \uC774\uD6C4 \uC815\uC815\uC740 \uC6D0\uCE59\uC801\uC73C\uB85C \uAE08\uC9C0\uB41C\uB2E4.", REF_HUNRYUNG)
1564
+ ];
1565
+ var LEVEL_PRINCIPLES = {
1566
+ elementary: [
1567
+ principle("\uCD08\uB4F1\uD559\uAD50 \uAD50\uACFC\uD559\uC2B5\uBC1C\uB2EC\uC0C1\uD669\uC740 \uC810\uC218\xB7\uC11D\uCC28 \uC5C6\uC774 \uC131\uCDE8\uAE30\uC900 \uB3C4\uB2EC \uC815\uB3C4\uB97C \uC11C\uC220\uD558\uACE0, \uD589\uB3D9\uD2B9\uC131 \uBC0F \uC885\uD569\uC758\uACAC\uC744 \uAD00\uCC30 \uADFC\uAC70\uB85C \uC791\uC131\uD55C\uB2E4.", REF_PORTAL),
1568
+ principle("\uC218\uC0C1\uACBD\uB825\uC5D0\uB294 \uAD50\uB0B4\uC0C1\uB9CC \uC785\uB825\uD558\uBA70 \uAD50\uC678 \uC218\uC0C1\xB7\uC678\uBD80 \uC2E4\uC801\uC740 \uAE30\uC7AC\uD558\uC9C0 \uC54A\uB294\uB2E4.", REF_HUNRYUNG)
1569
+ ],
1570
+ middle: [
1571
+ principle("\uC218\uC0C1\uACBD\uB825\uC5D0\uB294 \uAD50\uB0B4\uC0C1\uB9CC \uC785\uB825\uD558\uACE0, \uAC19\uC740 \uB0B4\uC6A9\uC73C\uB85C \uC5EC\uB7EC \uBC88 \uC218\uC0C1\uD55C \uACBD\uC6B0 \uCD5C\uC0C1\uC704 1\uAC1C\uB9CC \uC785\uB825\uD55C\uB2E4. \uAD50\uC678 \uC218\uC0C1\uC740 \uAE30\uC7AC\uD558\uC9C0 \uC54A\uB294\uB2E4.", REF_HUNRYUNG)
1572
+ ],
1573
+ high: [
1574
+ principle("\uC218\uC0C1\uACBD\uB825\uC5D0\uB294 \uAD50\uB0B4\uC0C1\uB9CC \uC785\uB825(\uCD5C\uC0C1\uC704 1\uAC1C)\uD558\uBA70, \uACF5\uC778\uC5B4\uD559\uC131\uC801\xB7\uAD50\uACFC \uAD00\uB828 \uAD50\uC678 \uC218\uC0C1\uC2E4\uC801\uC740 \uB300\uC785(\uD559\uC0DD\uBD80\uC704\uC8FC\uC804\uD615) \uC81C\uCD9C\uC774 \uAE08\uC9C0\uB41C\uB2E4.", REF_HUNRYUNG),
1575
+ principle("\uACE0\uAD50\uD559\uC810\uC81C \uAD00\uB828: \uACFC\uBAA9 \uBBF8\uC774\uC218(I) \uCC98\uB9AC\uC640 \uC131\uCDE8\uB3C4(A~E) \uC0B0\uCD9C \uB4F1 2026 \uAC1C\uC815\uC0AC\uD56D\uC744 \uB530\uB978\uB2E4(\uC0C1\uC138 \uAE30\uC900\uC740 \uC6D0\uBB38 \uD655\uC778).", REF_REVISION_2026),
1576
+ principle("\uD559\uAD50 \uBC16\uC5D0\uC11C \uD559\uC0DD\uC774 \uC2A4\uC2A4\uB85C \uC218\uD589\uD558\uB294 \uACFC\uC81C\uD615 \uC218\uD589\uD3C9\uAC00 \uACB0\uACFC\uB294 \uAE30\uB85D \uADFC\uAC70\uB85C \uC0BC\uC9C0 \uC54A\uB294\uB2E4(\uC218\uC5C5 \uC911 \uAD50\uC0AC \uAD00\uCC30 \uADFC\uAC70, 2026 \uAC1C\uC815).", REF_REVISION_2026)
1577
+ ]
1578
+ };
1579
+ var COMMON_PRINCIPLES_2025 = [
1580
+ principle("\uAD50\uC0AC\uAC00 \uC9C1\uC811 \uAD00\uCC30\xB7\uD3C9\uAC00\uD55C \uC0AC\uC2E4\uC744 \uADFC\uAC70\uB85C \uC785\uB825\uD558\uACE0, \uCD94\uCE21\xB7\uACFC\uC7A5\xB7\uBBF8\uAC80\uC99D \uB0B4\uC6A9\uC740 \uAE30\uC7AC\uD558\uC9C0 \uC54A\uB294\uB2E4.", REF_HUNRYUNG_2025),
1581
+ principle("\uB2E4\uC74C\uC740 \uC785\uB825\uD560 \uC218 \uC5C6\uB2E4: \uAD50\uB0B4\uC678 \uC778\uC99D\uC2DC\uD5D8 \uC131\uC801, \uBAA8\uC758\uACE0\uC0AC\xB7\uC804\uAD6D\uC5F0\uD569\uD559\uB825\uD3C9\uAC00 \uC131\uC801, \uB17C\uBB38 \uD22C\uACE0\xB7\uB4F1\uC7AC, \uB3C4\uC11C \uCD9C\uAC04, \uC9C0\uC2DD\uC7AC\uC0B0\uAD8C(\uD2B9\uD5C8\xB7\uC2E4\uC6A9\uC2E0\uC548 \uB4F1) \uCD9C\uC6D0\xB7\uB4F1\uB85D, \uC5B4\uD559\uC5F0\uC218 \uB4F1 \uD574\uC678 \uD65C\uB3D9\uC2E4\uC801, \uBD80\uBAA8\xB7\uCE5C\uC778\uCC99\uC758 \uC0AC\uD68C\xB7\uACBD\uC81C\uC801 \uC9C0\uC704 \uC554\uC2DC.", REF_HUNRYUNG_2025),
1582
+ principle("\uAD6C\uCCB4\uC801 \uC0AC\uB840 \uC911\uC2EC\uC73C\uB85C \uD559\uC0DD\uC758 \uC131\uC7A5\xB7\uBCC0\uD654\uB97C \uC11C\uC220\uD558\uACE0, \uB2E8\uC21C \uC0AC\uC2E4 \uB098\uC5F4\uC774\uB098 \uB2E8\uC815\uC801 \uD3C9\uAC00\uB294 \uD53C\uD55C\uB2E4.", REF_PORTAL),
1583
+ principle('"\uADFC\uAC70 \uC874\uC7AC"\uC640 "\uAE30\uC7AC \uAC00\uB2A5"\uC740 \uB2E4\uB974\uB2E4 \u2014 \uD559\uAD50\uAE09\xB7\uC5F0\uB3C4\uBCC4 \uAE30\uC7AC\uC694\uB839\uACFC \uC791\uC131 \uBC94\uC704\xB7\uAE08\uC9C0\uD45C\uD604\uC744 \uD568\uAED8 \uD655\uC778\uD55C\uB2E4.', REF_PORTAL),
1584
+ principle("\uCD5C\uC885 \uAE30\uC7AC \uC804 \uAD50\uC0AC\uAC00 \uADDC\uC815\uACFC \uC0AC\uC2E4\uC744 \uBC18\uB4DC\uC2DC \uAC80\uD1A0\uD55C\uB2E4. \uB9C8\uAC10 \uC774\uD6C4 \uC815\uC815\uC740 \uC6D0\uCE59\uC801\uC73C\uB85C \uAE08\uC9C0\uB41C\uB2E4.", REF_HUNRYUNG_2025)
1585
+ ];
1586
+ var LEVEL_PRINCIPLES_2025 = {
1587
+ elementary: [
1588
+ principle("\uCD08\uB4F1\uD559\uAD50 \uAD50\uACFC\uD559\uC2B5\uBC1C\uB2EC\uC0C1\uD669\uC740 \uC810\uC218\xB7\uC11D\uCC28 \uC5C6\uC774 \uC131\uCDE8\uAE30\uC900 \uB3C4\uB2EC \uC815\uB3C4\uB97C \uC11C\uC220\uD558\uACE0, \uD589\uB3D9\uD2B9\uC131 \uBC0F \uC885\uD569\uC758\uACAC\uC744 \uAD00\uCC30 \uADFC\uAC70\uB85C \uC791\uC131\uD55C\uB2E4.", REF_PORTAL),
1589
+ principle("\uC218\uC0C1\uACBD\uB825\uC5D0\uB294 \uAD50\uB0B4\uC0C1\uB9CC \uC785\uB825\uD558\uBA70 \uAD50\uC678 \uC218\uC0C1\xB7\uC678\uBD80 \uC2E4\uC801\uC740 \uAE30\uC7AC\uD558\uC9C0 \uC54A\uB294\uB2E4.", REF_HUNRYUNG_2025)
1590
+ ],
1591
+ middle: [
1592
+ principle("\uC218\uC0C1\uACBD\uB825\uC5D0\uB294 \uAD50\uB0B4\uC0C1\uB9CC \uC785\uB825\uD558\uACE0, \uAC19\uC740 \uB0B4\uC6A9\uC73C\uB85C \uC5EC\uB7EC \uBC88 \uC218\uC0C1\uD55C \uACBD\uC6B0 \uCD5C\uC0C1\uC704 1\uAC1C\uB9CC \uC785\uB825\uD55C\uB2E4. \uAD50\uC678 \uC218\uC0C1\uC740 \uAE30\uC7AC\uD558\uC9C0 \uC54A\uB294\uB2E4.", REF_HUNRYUNG_2025)
1593
+ ],
1594
+ high: [
1595
+ principle("\uC218\uC0C1\uACBD\uB825\uC5D0\uB294 \uAD50\uB0B4\uC0C1\uB9CC \uC785\uB825(\uCD5C\uC0C1\uC704 1\uAC1C)\uD558\uBA70, \uACF5\uC778\uC5B4\uD559\uC131\uC801\xB7\uAD50\uACFC \uAD00\uB828 \uAD50\uC678 \uC218\uC0C1\uC2E4\uC801\uC740 \uB300\uC785(\uD559\uC0DD\uBD80\uC704\uC8FC\uC804\uD615) \uC81C\uCD9C\uC774 \uAE08\uC9C0\uB41C\uB2E4.", REF_HUNRYUNG_2025),
1596
+ principle("2025\uD559\uB144\uB3C4 \uACE01\uBD80\uD130 \uAD50\uACFC \uC11D\uCC28\uB4F1\uAE09\uC774 9\uB4F1\uAE09\uC5D0\uC11C 5\uB4F1\uAE09 \uCCB4\uACC4\uB85C \uC804\uD658\uB418\uACE0(2022 \uAC1C\uC815\uAD50\uC721\uACFC\uC815 \uC801\uC6A9), \uC131\uCDE8\uB3C4\uBCC4 \uBD84\uD3EC\uBE44\uC728\uC774 \uBCF4\uD1B5\uAD50\uACFC \uC804 \uACFC\uBAA9\uC73C\uB85C \uD655\uB300\uB418\uBA70 \uD45C\uC900\uD3B8\uCC28\uB294 \uAE30\uC7AC \uB300\uC0C1\uC5D0\uC11C \uC81C\uC678\uB41C\uB2E4(\uC0C1\uC138 \uAE30\uC900\uC740 \uC6D0\uBB38 \uD655\uC778).", REF_REVISION_2025)
1597
+ ]
1598
+ };
1599
+ var YEAR_PACKS = {
1600
+ "2025": {
1601
+ commonPrinciples: COMMON_PRINCIPLES_2025,
1602
+ levelPrinciples: LEVEL_PRINCIPLES_2025,
1603
+ references: REFERENCES_2025
1604
+ },
1605
+ "2026": {
1606
+ commonPrinciples: COMMON_PRINCIPLES,
1607
+ levelPrinciples: LEVEL_PRINCIPLES,
1608
+ references: REFERENCES
1609
+ }
1610
+ };
1611
+ var RULE_PACK_YEARS = Object.keys(YEAR_PACKS).sort();
1612
+ var LATEST_YEAR = RULE_PACK_YEARS[RULE_PACK_YEARS.length - 1] ?? "2026";
1613
+ var LEGACY_HIGH_RISK_TERMS = [
1614
+ "\uC218\uC0C1",
1615
+ "\uB300\uC0C1",
1616
+ "\uC6B0\uC2B9",
1617
+ "\uCD5C\uC6B0\uC218",
1618
+ "\uAE08\uC0C1",
1619
+ "\uC740\uC0C1",
1620
+ "\uB3D9\uC0C1",
1621
+ "\uC785\uC0C1",
1622
+ "\uD45C\uCC3D",
1623
+ "\uC7A5\uD559",
1624
+ "\uC790\uACA9\uC99D",
1625
+ "\uD569\uACA9",
1626
+ "\uC9C4\uB2E8",
1627
+ "\uC7A5\uC560",
1628
+ "\uB4F1\uAE09",
1629
+ "1\uB4F1",
1630
+ "\uC77C\uB4F1",
1631
+ "\uAE08\uBA54\uB2EC"
1632
+ ];
1633
+ var PROHIBITED_ITEM_TERMS = [
1634
+ "\uD2B9\uD5C8",
1635
+ "\uB17C\uBB38",
1636
+ "\uC800\uC11C",
1637
+ "\uCD9C\uAC04",
1638
+ "\uACF5\uC778\uC5B4\uD559",
1639
+ "\uD1A0\uC775",
1640
+ "\uD1A0\uD50C",
1641
+ "\uBAA8\uC758\uACE0\uC0AC",
1642
+ "\uD559\uB825\uD3C9\uAC00"
1643
+ ];
1644
+ var LEVEL_HIGH_RISK_TERMS = {
1645
+ elementary: [],
1646
+ middle: [],
1647
+ // 고등학교: 대입 제출 금지(공인어학) 관련 어휘 보강
1648
+ high: ["\uD15D\uC2A4", "\uC624\uD53D"]
1649
+ };
1650
+ function resolveLevel(level) {
1651
+ if (level === void 0)
1652
+ return void 0;
1653
+ const key = level.trim().normalize("NFC");
1654
+ const map = {
1655
+ elementary: "elementary",
1656
+ middle: "middle",
1657
+ high: "high",
1658
+ \uCD08\uB4F1\uD559\uAD50: "elementary",
1659
+ \uC911\uD559\uAD50: "middle",
1660
+ \uACE0\uB4F1\uD559\uAD50: "high"
1661
+ };
1662
+ const resolved = map[key];
1663
+ if (resolved === void 0) {
1664
+ throw new Error("\uC54C \uC218 \uC5C6\uB294 \uD559\uAD50\uAE09\uC785\uB2C8\uB2E4. \uD5C8\uC6A9\uAC12: elementary|middle|high \uB610\uB294 \uCD08\uB4F1\uD559\uAD50|\uC911\uD559\uAD50|\uACE0\uB4F1\uD559\uAD50");
1665
+ }
1666
+ return resolved;
1667
+ }
1668
+ function resolveYear(year) {
1669
+ if (year === void 0)
1670
+ return LATEST_YEAR;
1671
+ if (!RULE_PACK_YEARS.includes(year)) {
1672
+ throw new Error(`\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uAE30\uC7AC\uC694\uB839 \uC5F0\uB3C4\uC785\uB2C8\uB2E4. \uC9C0\uC6D0 \uC5F0\uB3C4: ${RULE_PACK_YEARS.join(", ")}`);
1673
+ }
1674
+ return year;
1675
+ }
1676
+ function resolveRulePack(query = {}) {
1677
+ const version = resolveYear(query.year);
1678
+ const pack = YEAR_PACKS[version];
1679
+ if (!pack) {
1680
+ throw new Error(`\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uAE30\uC7AC\uC694\uB839 \uC5F0\uB3C4\uC785\uB2C8\uB2E4. \uC9C0\uC6D0 \uC5F0\uB3C4: ${RULE_PACK_YEARS.join(", ")}`);
1681
+ }
1682
+ const level = resolveLevel(query.level);
1683
+ const citations = level ? [...pack.commonPrinciples, ...pack.levelPrinciples[level]] : pack.commonPrinciples;
1684
+ const highRiskTerms = level ? [...LEGACY_HIGH_RISK_TERMS, ...PROHIBITED_ITEM_TERMS, ...LEVEL_HIGH_RISK_TERMS[level]] : LEGACY_HIGH_RISK_TERMS;
1685
+ return {
1686
+ version,
1687
+ level: level ?? "common",
1688
+ levelLabel: level ? LEVEL_LABELS[level] : "\uACF5\uD1B5",
1689
+ citations,
1690
+ highRiskTerms,
1691
+ references: pack.references
1692
+ };
1693
+ }
1694
+ function bigrams(s) {
1695
+ const t = s.replace(/\s+/g, "");
1696
+ const set = /* @__PURE__ */ new Set();
1697
+ for (let i = 0; i + 1 < t.length; i += 1)
1698
+ set.add(t.slice(i, i + 2));
1699
+ return set;
1700
+ }
1701
+ function claimCoverage(claim, content) {
1702
+ const A = bigrams(claim);
1703
+ if (A.size === 0)
1704
+ return 0;
1705
+ const B = bigrams(content);
1706
+ let inter = 0;
1707
+ for (const g of A)
1708
+ if (B.has(g))
1709
+ inter += 1;
1710
+ return inter / A.size;
1711
+ }
1712
+ function maxUncoveredRun(claim, contents) {
1713
+ const t = claim.replace(/\s+/g, "");
1714
+ if (t.length < 2)
1715
+ return 0;
1716
+ const covered = /* @__PURE__ */ new Set();
1717
+ for (const c of contents)
1718
+ for (const g of bigrams(c))
1719
+ covered.add(g);
1720
+ let maxRun = 0;
1721
+ let run = 0;
1722
+ for (let i = 0; i + 1 < t.length; i += 1) {
1723
+ if (covered.has(t.slice(i, i + 2))) {
1724
+ run = 0;
1725
+ } else {
1726
+ run += 1;
1727
+ if (run > maxRun)
1728
+ maxRun = run;
1729
+ }
1730
+ }
1731
+ return maxRun;
1732
+ }
1733
+ function hasUnverifiedHighRiskTerm(claim, contents, terms) {
1734
+ const joined = contents.join(" ");
1735
+ return terms.some((raw) => {
1736
+ const term = raw.normalize("NFC");
1737
+ return claim.includes(term) && !joined.includes(term);
1738
+ });
1739
+ }
1740
+ function sentenceSegments(text) {
1741
+ return text.split(/[.!?。]+/).map((s) => s.trim()).filter((s) => s.length > 0);
1742
+ }
1743
+ function checkGrounding(claims, observations, query = {}) {
1744
+ const pack = resolveRulePack(query);
1745
+ const highRiskTerms = pack.highRiskTerms;
1746
+ const byId = /* @__PURE__ */ new Map();
1747
+ for (const o of observations)
1748
+ byId.set(o.id, o.content.normalize("NFC"));
1749
+ const checks = claims.map((claim) => {
1750
+ const text = typeof claim.text === "string" ? claim.text.trim().normalize("NFC") : "";
1751
+ const citedIds = claim.observationIds ?? [];
1752
+ const validIds = citedIds.filter((id) => byId.has(id));
1753
+ const unknownIds = citedIds.filter((id) => !byId.has(id));
1754
+ const flags = [];
1755
+ let coverage = 0;
1756
+ if (text.length === 0) {
1757
+ flags.push("empty");
1758
+ } else {
1759
+ if (sentenceSegments(text).length > 1)
1760
+ flags.push("multi_sentence");
1761
+ if (unknownIds.length > 0)
1762
+ flags.push("unknown_citation");
1763
+ if (validIds.length === 0) {
1764
+ flags.push("no_citation");
1765
+ } else {
1766
+ const contents = validIds.map((id) => byId.get(id) ?? "");
1767
+ for (const content of contents)
1768
+ coverage = Math.max(coverage, claimCoverage(text, content));
1769
+ if (coverage < COVERAGE_THRESHOLD)
1770
+ flags.push("low_overlap");
1771
+ else if (maxUncoveredRun(text, contents) >= UNCOVERED_RUN_MAX)
1772
+ flags.push("partial_unsupported");
1773
+ if (hasUnverifiedHighRiskTerm(text, contents, highRiskTerms))
1774
+ flags.push("unverified_high_risk_term");
1775
+ }
1776
+ }
1777
+ return { text, citedIds, validIds, unknownIds, coverage: Number(coverage.toFixed(3)), flags };
1778
+ });
1779
+ const flaggedCount = checks.filter((c) => c.flags.length > 0).length;
1780
+ return {
1781
+ total: checks.length,
1782
+ flaggedCount,
1783
+ claims: checks,
1784
+ rulePackVersion: pack.version,
1785
+ rulePackLevel: pack.level,
1786
+ requiresTeacherReview: true,
1787
+ disclaimer: DISCLAIMER
1788
+ };
1789
+ }
1790
+ function recordGuidelines(query = {}) {
1791
+ const pack = resolveRulePack(query);
1792
+ return {
1793
+ version: pack.version,
1794
+ level: pack.level,
1795
+ levelLabel: pack.levelLabel,
1796
+ principles: pack.citations.map((c) => c.text),
1797
+ citations: pack.citations,
1798
+ source: REF_PORTAL.url,
1799
+ references: pack.references,
1800
+ disclaimer: DISCLAIMER_GUIDE
1801
+ };
1802
+ }
1803
+
1804
+ // ../packages/core/dist/access.js
1805
+ function getObservationsForIdentity(dataDir, identity, query = {}) {
1806
+ if (identity.kind === "teaching") {
1807
+ return getTeachingObservations(dataDir, identity.classId, identity.studentKey, query);
1808
+ }
1809
+ if (identity.kind === "homeroom") {
1810
+ return getStudentObservations(dataDir, identity.studentId, query).filter((r) => !r.classId);
1811
+ }
1812
+ return [];
1813
+ }
1814
+ function rosterForIdentity(dataDir, identity, store) {
1815
+ if (identity.kind === "teaching") {
1816
+ const cls = readTeachingClasses(dataDir).classes.find((c) => c.id === identity.classId);
1817
+ return cls ? rosterFromTeachingClass(cls, store) : [];
1818
+ }
1819
+ if (identity.kind === "homeroom") {
1820
+ return readStudents(dataDir).map((s) => {
1821
+ const entry = { token: store.getToken(s.id), names: [s.name] };
1822
+ return s.studentNumber === void 0 ? entry : { ...entry, studentNumber: s.studentNumber };
1823
+ });
1824
+ }
1825
+ return [];
1826
+ }
1827
+
1828
+ // ../packages/core/dist/assessments.js
1829
+ var NUM = "\\d[\\d,]*(?:\\.\\d+)?(?:\\s*[~\u223C\u301C\u2010\u2011\u2013\u2014\\-]\\s*\\d[\\d,]*(?:\\.\\d+)?)?";
1830
+ var SCORE_RULES = [
1831
+ [new RegExp(`${NUM}\\s*\uBD84\uC758\\s*${NUM}`, "g"), "[\uC810\uC218]"],
1832
+ // 한국어 분수 'N분의 M'
1833
+ [new RegExp(`${NUM}\\s*/\\s*${NUM}`, "g"), "[\uC810\uC218]"],
1834
+ // 'M/N'
1835
+ [new RegExp(`${NUM}\\s*\uC810\\s*\uB9CC\uC810`, "g"), "[\uB9CC\uC810]"],
1836
+ [new RegExp(`${NUM}\\s*\uB9CC\uC810`, "g"), "[\uB9CC\uC810]"],
1837
+ // '점' 없는 만점(100만점/5 만점)
1838
+ [new RegExp(`${NUM}\\s*\uC810`, "g"), "[\uC810\uC218]"],
1839
+ [new RegExp(`${NUM}\\s*\uB4F1\uAE09`, "g"), "[\uB4F1\uAE09]"],
1840
+ // 라벨형: 석차/순위/등수 + (콜론·공백) + 숫자(+등) — '석차: 2'·'순위: 1'·'등수 3'·'학급석차: 4'
1841
+ [new RegExp(`(?:\uC11D\uCC28|\uC21C\uC704|\uB4F1\uC218)\\s*[:\uFF1A]?\\s*${NUM}\\s*\uB4F1?`, "g"), "[\uC11D\uCC28]"],
1842
+ [new RegExp(`${NUM}\\s*\uC21C\uC704`, "g"), "[\uC11D\uCC28]"],
1843
+ // 숫자-선행 '1순위'
1844
+ // 숫자+등/위(석차)는 모두 마스킹. 무공백 합성(3등분야/3위로/100위안)을 정규식으로 완벽 구분 불가 →
1845
+ // 누출 0 우선 전부 치환(등급은 위에서 선처리, 숫자 없는 등교/위원 등은 미매칭 보존).
1846
+ [new RegExp(`${NUM}\\s*\uB4F1`, "g"), "[\uC11D\uCC28]"],
1847
+ [new RegExp(`${NUM}\\s*\uC704`, "g"), "[\uC11D\uCC28]"],
1848
+ [new RegExp(`${NUM}\\s*(?:%|\uFF05|\uD37C\uC13C\uD2B8|\uD504\uB85C)`, "g"), "[\uBE44\uC728]"]
1849
+ ];
1850
+ function maskScores(text) {
1851
+ let out = text;
1852
+ for (const [re, rep] of SCORE_RULES)
1853
+ out = out.replace(re, rep);
1854
+ return out;
1855
+ }
1856
+ function safeAchievement(level) {
1857
+ if (level === void 0)
1858
+ return void 0;
1859
+ const t = level.trim();
1860
+ if (t.length === 0 || t.length > 4 || /\d/.test(t))
1861
+ return void 0;
1862
+ return t;
1863
+ }
1864
+ function setIf11(target, key, value) {
1865
+ if (value !== void 0 && value !== "")
1866
+ target[key] = value;
1867
+ }
1868
+ function getRubricFeedback(dataDir, classId, studentKey2, maskText) {
1869
+ const scrub = (s) => maskScores(maskText(s));
1870
+ const { rubrics, gradings } = readRubrics(dataDir);
1871
+ const rubricById = new Map(rubrics.map((r) => [r.id, r]));
1872
+ return gradings.filter((g) => g.classId === classId && g.studentId === studentKey2).map((g) => {
1873
+ const found = rubricById.get(g.rubricId);
1874
+ const rubric = found && found.classId === classId ? found : void 0;
1875
+ const criteria = (rubric?.criteria ?? []).slice().sort((a, b) => a.order - b.order).map((c) => {
1876
+ const levelId = g.marks[c.id];
1877
+ const level = levelId ? c.levels.find((l) => l.id === levelId) : void 0;
1878
+ const fb = {
1879
+ criterion: c.name,
1880
+ achievedLevel: level?.name ?? null
1881
+ };
1882
+ if (level?.description)
1883
+ setIf11(fb, "levelDescription", scrub(level.description));
1884
+ const note = g.criterionNotes[c.id];
1885
+ if (note)
1886
+ setIf11(fb, "note", scrub(note));
1887
+ return fb;
1888
+ });
1889
+ const view = {
1890
+ rubricTitle: rubric?.title ?? "(\uC0AD\uC81C\uB41C \uD3C9\uAC00\uD45C)",
1891
+ status: g.status,
1892
+ criteria,
1893
+ date: g.gradedAt
1894
+ };
1895
+ if (g.overallFeedback)
1896
+ setIf11(view, "overallFeedback", scrub(g.overallFeedback));
1897
+ return view;
1898
+ });
1899
+ }
1900
+ function writtenParticipation(absence, scorePresent) {
1901
+ if (absence === "absent")
1902
+ return "\uACB0\uC2DC";
1903
+ if (absence === "recognized")
1904
+ return "\uC778\uC815";
1905
+ if (absence === "exempt")
1906
+ return "\uBA74\uC81C";
1907
+ return scorePresent ? "\uC751\uC2DC" : "\uBBF8\uC785\uB825";
1908
+ }
1909
+ function getGradeSummary(dataDir, classId, studentKey2, maskText) {
1910
+ const scrub = (s) => maskScores(maskText(s));
1911
+ const cls = readTeachingClasses(dataDir).classes.find((c) => c.id === classId);
1912
+ const student = cls?.students.find((s) => studentKey(s) === studentKey2);
1913
+ if (!student)
1914
+ return { achievement: [], assessments: [] };
1915
+ const gKey = gradeStudentKey(student);
1916
+ const ga = readGradeAnalysis(dataDir);
1917
+ const achievement = [];
1918
+ for (const r of ga.semesterResults) {
1919
+ if (r.teachingClassId !== classId || r.studentKey !== gKey)
1920
+ continue;
1921
+ const level = safeAchievement(r.achievementLevel);
1922
+ if (level)
1923
+ achievement.push({ semester: r.semester, level, confirmed: r.confirmed });
1924
+ }
1925
+ const plans = ga.plans.filter((p) => p.teachingClassId === classId);
1926
+ const assessments = plans.map((p) => {
1927
+ const summary = {
1928
+ area: p.areaName,
1929
+ kind: p.kind === "written-exam" ? "\uC9C0\uD544" : "\uC218\uD589",
1930
+ title: p.title,
1931
+ participation: "\uBBF8\uC785\uB825",
1932
+ confirmed: false
1933
+ };
1934
+ if (p.method)
1935
+ setIf11(summary, "method", scrub(p.method));
1936
+ if (p.kind === "written-exam") {
1937
+ const w = ga.writtenResults.find((x) => x.assessmentId === p.id && x.studentKey === gKey);
1938
+ if (w) {
1939
+ summary["participation"] = writtenParticipation(w.absenceCode, w.scorePresent);
1940
+ summary["confirmed"] = w.confirmed;
1941
+ }
1942
+ } else {
1943
+ const pr = ga.performanceResults.find((x) => x.assessmentId === p.id && x.studentKey === gKey);
1944
+ if (pr) {
1945
+ summary["participation"] = pr.scorePresent ? "\uC751\uC2DC" : "\uBBF8\uC785\uB825";
1946
+ summary["confirmed"] = pr.confirmed;
1947
+ if (pr.evidenceNote)
1948
+ setIf11(summary, "evidenceNote", scrub(pr.evidenceNote));
1949
+ }
1950
+ }
1951
+ return summary;
1952
+ });
1953
+ return { achievement, assessments };
1954
+ }
1955
+
1956
+ // ../packages/core/dist/consent.js
1957
+ import crypto4 from "node:crypto";
1958
+ import fs6 from "node:fs";
1959
+ import path5 from "node:path";
1960
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1961
+ var PURPOSE_MAX = 100;
1962
+ var OBSERVATION_READ_PURPOSE = "observation_read";
1963
+ var ConsentValidationError = class extends Error {
1964
+ name = "ConsentValidationError";
1965
+ };
1966
+ function nowIso() {
1967
+ return (/* @__PURE__ */ new Date()).toISOString();
1968
+ }
1969
+ var CONSENT_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
1970
+ function defaultConsentId() {
1971
+ const bytes = crypto4.randomBytes(16);
1972
+ let s = "";
1973
+ for (const b of bytes)
1974
+ s += CONSENT_ID_ALPHABET[b % 26];
1975
+ return `cs_${s}`;
1976
+ }
1977
+ function notExpired(rec, t) {
1978
+ if (rec.expiresAt === void 0)
1979
+ return true;
1980
+ const exp = Date.parse(rec.expiresAt);
1981
+ const at = Date.parse(t);
1982
+ if (Number.isNaN(exp) || Number.isNaN(at))
1983
+ return false;
1984
+ return at <= exp;
1985
+ }
1986
+ function isValidRecord(v) {
1987
+ if (typeof v !== "object" || v === null)
1988
+ return false;
1989
+ const r = v;
1990
+ if (typeof r["id"] !== "string" || r["id"].length === 0)
1991
+ return false;
1992
+ if (typeof r["studentId"] !== "string" || r["studentId"].length === 0)
1993
+ return false;
1994
+ if (typeof r["grantedAt"] !== "string")
1995
+ return false;
1996
+ for (const k of ["purpose", "from", "to", "expiresAt"]) {
1997
+ if (r[k] !== void 0 && typeof r[k] !== "string")
1998
+ return false;
1999
+ }
2000
+ return true;
2001
+ }
2002
+ var ConsentStore = class {
2003
+ filePath;
2004
+ genId;
2005
+ lockTimeoutMs;
2006
+ constructor(opts = {}) {
2007
+ const base = opts.dir ?? resolveDataDir();
2008
+ this.filePath = path5.join(bridgeStateDir(base), "consents.json");
2009
+ this.genId = opts.genId ?? defaultConsentId;
2010
+ this.lockTimeoutMs = opts.lockTimeoutMs ?? 3e3;
2011
+ }
2012
+ /** 파일을 새로 읽어 유효 레코드만 반환(손상 항목은 조용히 폐기). */
2013
+ read() {
2014
+ let parsed;
2015
+ try {
2016
+ parsed = JSON.parse(fs6.readFileSync(this.filePath, "utf-8"));
2017
+ } catch {
2018
+ return [];
2019
+ }
2020
+ const records = parsed?.records;
2021
+ if (!Array.isArray(records))
2022
+ return [];
2023
+ return records.filter(isValidRecord);
2024
+ }
2025
+ /** 동기 sleep(잠금 재시도 백오프) — Atomics.wait 으로 CPU 스핀 없이 대기. */
2026
+ sleep(ms) {
2027
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2028
+ }
2029
+ /**
2030
+ * pid 가 살아있는가(존재=true). ESRCH(프로세스 없음)만 사망으로 보고, 그 외 오류(EPERM 등)는
2031
+ * 보수적으로 생존 취급한다 — 확신 없는 회수를 막아 데이터 무결성을 우선(fail-closed).
2032
+ */
2033
+ pidAlive(pid) {
2034
+ try {
2035
+ process.kill(pid, 0);
2036
+ return true;
2037
+ } catch (e) {
2038
+ return e.code !== "ESRCH";
2039
+ }
2040
+ }
2041
+ /**
2042
+ * 점유된 잠금이 안전하게 회수 가능하면 회수하고 true.
2043
+ * - 소유자 pid 가 살아있으면 회수하지 않는다(살아있는 writer 의 락을 빼앗지 않음).
2044
+ * - 소유자 pid 가 죽었으면 회수. (pid 재사용 시엔 보수적으로 살아있다고 보고 대기 → fail-closed)
2045
+ * - 소유자 정보가 없고(획득 직후 크래시) 잠금이 충분히 오래(>30s)면 보수적 lease 로 회수.
2046
+ */
2047
+ reclaimIfStale(lockDir, ownerFile) {
2048
+ let owner;
2049
+ try {
2050
+ owner = JSON.parse(fs6.readFileSync(ownerFile, "utf-8"));
2051
+ } catch {
2052
+ owner = void 0;
2053
+ }
2054
+ if (owner && typeof owner.pid === "number") {
2055
+ if (this.pidAlive(owner.pid))
2056
+ return false;
2057
+ try {
2058
+ fs6.rmSync(lockDir, { recursive: true, force: true });
2059
+ return true;
2060
+ } catch {
2061
+ return false;
2062
+ }
2063
+ }
2064
+ try {
2065
+ if (Date.now() - fs6.statSync(lockDir).mtimeMs > 3e4) {
2066
+ fs6.rmSync(lockDir, { recursive: true, force: true });
2067
+ return true;
2068
+ }
2069
+ } catch {
2070
+ }
2071
+ return false;
2072
+ }
2073
+ /**
2074
+ * 소유권 기반 배타 잠금으로 read-modify-write 를 직렬화(동시 grant/revoke 갱신 손실 방지).
2075
+ * - 획득: 원자적 mkdir + owner 파일에 {pid, nonce} 기록.
2076
+ * - 해제: owner.nonce 가 내 것일 때만 제거(중간에 회수당했으면 남의 락을 지우지 않음).
2077
+ * - 회수: 소유자 pid 사망 시에만(또는 소유자 정보 없이 오래된 경우) 회수.
2078
+ */
2079
+ withLock(fn) {
2080
+ const lockDir = `${this.filePath}.lock`;
2081
+ const ownerFile = path5.join(lockDir, "owner.json");
2082
+ const myNonce = `${process.pid}.${crypto4.randomBytes(6).toString("hex")}`;
2083
+ const deadlineMs = Date.now() + this.lockTimeoutMs;
2084
+ fs6.mkdirSync(path5.dirname(this.filePath), { recursive: true });
2085
+ for (; ; ) {
2086
+ try {
2087
+ fs6.mkdirSync(lockDir);
2088
+ fs6.writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, nonce: myNonce }), "utf-8");
2089
+ break;
2090
+ } catch {
2091
+ if (this.reclaimIfStale(lockDir, ownerFile))
2092
+ continue;
2093
+ if (Date.now() > deadlineMs) {
2094
+ throw new Error("\uB3D9\uC758 \uC800\uC7A5\uC18C \uC7A0\uAE08 \uD68D\uB4DD \uC2E4\uD328(\uB2E4\uB978 \uC791\uC5C5\uC774 \uC9C4\uD589 \uC911\uC77C \uC218 \uC788\uC2B5\uB2C8\uB2E4).");
2095
+ }
2096
+ this.sleep(30);
2097
+ }
2098
+ }
2099
+ try {
2100
+ return fn();
2101
+ } finally {
2102
+ try {
2103
+ const owner = JSON.parse(fs6.readFileSync(ownerFile, "utf-8"));
2104
+ if (owner.nonce === myNonce)
2105
+ fs6.rmSync(lockDir, { recursive: true, force: true });
2106
+ } catch {
2107
+ }
2108
+ }
2109
+ }
2110
+ /** 고유 tmp + 원자적 rename(잠금 보유 중 호출). 고정 tmp 경로 충돌을 피한다. */
2111
+ persist(records) {
2112
+ const dir = path5.dirname(this.filePath);
2113
+ fs6.mkdirSync(dir, { recursive: true });
2114
+ const tmp = `${this.filePath}.${process.pid}.${crypto4.randomBytes(4).toString("hex")}.tmp`;
2115
+ const body = { records: [...records] };
2116
+ fs6.writeFileSync(tmp, JSON.stringify(body, null, 2), "utf-8");
2117
+ fs6.renameSync(tmp, this.filePath);
2118
+ }
2119
+ /** 잠금 하에서 read → 변형 → write 를 원자적으로 수행(갱신 손실 방지). */
2120
+ mutate(fn) {
2121
+ this.withLock(() => {
2122
+ this.persist(fn(this.read()));
2123
+ });
2124
+ }
2125
+ /** 동의 부여(교사 권한). 검증 후 레코드 생성·영속하고 레코드를 반환. */
2126
+ grant(input) {
2127
+ const studentId = input.studentId;
2128
+ if (typeof studentId !== "string" || studentId.trim().length === 0) {
2129
+ throw new ConsentValidationError("studentId \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.");
2130
+ }
2131
+ const record = {
2132
+ id: this.genId(),
2133
+ studentId,
2134
+ grantedAt: nowIso()
2135
+ };
2136
+ if (input.purpose !== void 0) {
2137
+ const p = input.purpose.trim();
2138
+ if (p.length === 0 || p.length > PURPOSE_MAX) {
2139
+ throw new ConsentValidationError(`purpose \uB294 1~${PURPOSE_MAX}\uC790\uC5EC\uC57C \uD569\uB2C8\uB2E4.`);
2140
+ }
2141
+ record.purpose = p;
2142
+ }
2143
+ for (const k of ["from", "to"]) {
2144
+ const val = input[k];
2145
+ if (val !== void 0) {
2146
+ if (!DATE_RE.test(val))
2147
+ throw new ConsentValidationError(`${k} \uB294 YYYY-MM-DD \uD615\uC2DD\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.`);
2148
+ record[k] = val;
2149
+ }
2150
+ }
2151
+ if (record.from !== void 0 && record.to !== void 0 && record.from > record.to) {
2152
+ throw new ConsentValidationError("from \uC740 to \uBCF4\uB2E4 \uC774\uD6C4\uC77C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
2153
+ }
2154
+ if (input.expiresAt !== void 0 && input.ttlSeconds !== void 0) {
2155
+ throw new ConsentValidationError("expiresAt \uC640 ttlSeconds \uB294 \uB3D9\uC2DC\uC5D0 \uC9C0\uC815\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4(\uD558\uB098\uB9CC).");
2156
+ }
2157
+ if (input.expiresAt !== void 0) {
2158
+ if (Number.isNaN(Date.parse(input.expiresAt))) {
2159
+ throw new ConsentValidationError("expiresAt \uB294 \uC720\uD6A8\uD55C ISO \uC2DC\uAC01\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.");
2160
+ }
2161
+ record.expiresAt = input.expiresAt;
2162
+ } else if (input.ttlSeconds !== void 0) {
2163
+ if (!Number.isFinite(input.ttlSeconds) || input.ttlSeconds <= 0) {
2164
+ throw new ConsentValidationError("ttlSeconds \uB294 \uC591\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
2165
+ }
2166
+ record.expiresAt = new Date(Date.now() + input.ttlSeconds * 1e3).toISOString();
2167
+ }
2168
+ this.mutate((records) => [...records, record]);
2169
+ return record;
2170
+ }
2171
+ /** 동의 철회. 제거되면 true. */
2172
+ revoke(id) {
2173
+ let removed = false;
2174
+ this.mutate((records) => {
2175
+ const next = records.filter((r) => r.id !== id);
2176
+ removed = next.length !== records.length;
2177
+ return next;
2178
+ });
2179
+ return removed;
2180
+ }
2181
+ /** 특정 학생의 모든 동의를 철회(교사 일괄 정리). 제거된 동의 id 목록을 반환. */
2182
+ revokeForStudent(studentId) {
2183
+ const removed = [];
2184
+ this.mutate((records) => records.filter((r) => {
2185
+ if (r.studentId === studentId) {
2186
+ removed.push(r.id);
2187
+ return false;
2188
+ }
2189
+ return true;
2190
+ }));
2191
+ return removed;
2192
+ }
2193
+ /** 모든 동의를 철회(교사 전체 초기화). 제거된 동의 id 목록을 반환. */
2194
+ revokeAll() {
2195
+ const removed = [];
2196
+ this.mutate((records) => {
2197
+ for (const r of records)
2198
+ removed.push(r.id);
2199
+ return [];
2200
+ });
2201
+ return removed;
2202
+ }
2203
+ /** 만료되지 않은 동의 목록(기본 현재 기준). */
2204
+ list(at = nowIso()) {
2205
+ return this.read().filter((r) => notExpired(r, at));
2206
+ }
2207
+ /** 만료된 동의까지 포함한 전체 목록(교사 점검용 — describeConsent 로 상태 표시). */
2208
+ listAll() {
2209
+ return this.read();
2210
+ }
2211
+ /**
2212
+ * 질의에 부합하는 첫 활성 동의를 반환(없으면 undefined). 존재 확인용.
2213
+ * 매칭: studentId 일치 + 미만료 + (레코드 purpose 미지정이면 모든 목적 허용, 지정이면 동일 목적만).
2214
+ */
2215
+ findActive(query) {
2216
+ const at = query.at ?? nowIso();
2217
+ return this.read().find((r) => matchesQuery(r, query, at));
2218
+ }
2219
+ /** 질의에 부합하는 모든 활성 동의(기간 합집합 산정용 — 순서 의존성 제거). */
2220
+ findAllActive(query) {
2221
+ const at = query.at ?? nowIso();
2222
+ return this.read().filter((r) => matchesQuery(r, query, at));
2223
+ }
2224
+ };
2225
+ function matchesQuery(r, query, at) {
2226
+ return r.studentId === query.studentId && notExpired(r, at) && (r.purpose === void 0 || r.purpose === query.purpose);
2227
+ }
2228
+ function resolveContentAccess(query) {
2229
+ if (isContentExposureEnabled(query.env ?? process.env)) {
2230
+ return { allowed: true, via: "master" };
2231
+ }
2232
+ if (query.consent) {
2233
+ const cq = { studentId: query.studentId };
2234
+ if (query.purpose !== void 0)
2235
+ cq.purpose = query.purpose;
2236
+ if (query.at !== void 0)
2237
+ cq.at = query.at;
2238
+ const consents = query.consent.findAllActive(cq);
2239
+ if (consents.length > 0)
2240
+ return { allowed: true, via: "consent", consents };
2241
+ }
2242
+ return { allowed: false };
2243
+ }
2244
+ function isObservationDateAllowed(access, date) {
2245
+ if (access.via === "master")
2246
+ return true;
2247
+ return access.consents.some((c) => (c.from === void 0 || date >= c.from) && (c.to === void 0 || date <= c.to));
2248
+ }
2249
+ function assertContentAccess(query) {
2250
+ const access = resolveContentAccess(query);
2251
+ if (!access.allowed) {
2252
+ throw new ContentExposureDisabledError("\uB0B4\uC6A9 \uB178\uCD9C\uC774 \uD5C8\uAC00\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uAD50\uC0AC\uAC00 CLI \uB85C \uD574\uB2F9 \uD559\uC0DD \uB3D9\uC758\uB97C \uBD80\uC5EC(ssampin consent grant)\uD558\uAC70\uB098, SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uB9C8\uC2A4\uD130 \uC2A4\uC704\uCE58\uB97C \uD65C\uC131\uD654\uD558\uC138\uC694.");
2253
+ }
2254
+ return access;
2255
+ }
2256
+
2257
+ // ../packages/mcp/dist/context.js
2258
+ function createContext(dataDir = resolveDataDir()) {
2259
+ return {
2260
+ dataDir,
2261
+ store: new TokenStore({ dir: dataDir }),
2262
+ audit: new AuditLog(dataDir),
2263
+ consent: new ConsentStore({ dir: dataDir })
2264
+ };
2265
+ }
2266
+
2267
+ // ../packages/mcp/dist/tools.js
2268
+ function looksLikeToken(seg) {
2269
+ if (/^[0-9a-fA-F]{16,}$/.test(seg))
2270
+ return true;
2271
+ if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/.test(seg))
2272
+ return true;
2273
+ if (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(seg))
2274
+ return true;
2275
+ if (seg.length >= 16 && /^[A-Za-z0-9_-]+$/.test(seg)) {
2276
+ const mixedAlnum = /[A-Za-z]/.test(seg) && /[0-9]/.test(seg);
2277
+ const mixedCase = /[a-z]/.test(seg) && /[A-Z]/.test(seg);
2278
+ if (mixedAlnum || mixedCase)
2279
+ return true;
2280
+ }
2281
+ return false;
2282
+ }
2283
+ function redactPathTokens(pathname) {
2284
+ return pathname.split("/").map((seg) => looksLikeToken(seg) ? "[\uD1A0\uD070]" : seg).join("/");
2285
+ }
2286
+ function stripUrlSecrets(url) {
2287
+ try {
2288
+ const u = new URL(url);
2289
+ return `${u.protocol}//${u.host}${redactPathTokens(u.pathname)}`;
2290
+ } catch {
2291
+ const noFrag = url.split("#")[0] ?? url;
2292
+ const noQuery = noFrag.split("?")[0] ?? noFrag;
2293
+ return redactPathTokens(noQuery);
2294
+ }
2295
+ }
2296
+ function makeDeider(roster) {
2297
+ let masked = 0;
2298
+ return {
2299
+ deid: (s) => {
2300
+ const r = deidentify(s, roster);
2301
+ masked += sumDeid(r.stats);
2302
+ return r.text;
2303
+ },
2304
+ masked: () => masked
2305
+ };
2306
+ }
2307
+ var UnknownTokenError = class extends Error {
2308
+ name = "UnknownTokenError";
2309
+ };
2310
+ function resolveStudentTarget(ctx, token) {
2311
+ if (/^(?:cls|obs)_/.test(token)) {
2312
+ throw new UnknownTokenError("\uD559\uC0DD \uD1A0\uD070\uC774 \uC544\uB2D9\uB2C8\uB2E4(\uC218\uC5C5\uBC18/\uAD00\uCC30 \uD1A0\uD070). list_students \uC758 \uD559\uC0DD token \uC744 \uC4F0\uC138\uC694.");
2313
+ }
2314
+ const resolved = ctx.store.resolveToken(token);
2315
+ if (!resolved) {
2316
+ throw new UnknownTokenError("\uC54C \uC218 \uC5C6\uB294 \uD559\uC0DD \uD1A0\uD070\uC785\uB2C8\uB2E4. \uBA3C\uC800 list_students \uB85C \uD1A0\uD070\uC744 \uD655\uC778\uD558\uC138\uC694.");
2317
+ }
2318
+ const identity = parseIdentity(resolved);
2319
+ if (identity.kind === "class") {
2320
+ throw new UnknownTokenError("\uC218\uC5C5\uBC18 \uD1A0\uD070\uC744 \uD559\uC0DD \uD1A0\uD070 \uC790\uB9AC\uC5D0 \uC0AC\uC6A9\uD588\uC2B5\uB2C8\uB2E4. list_students \uC758 \uD559\uC0DD token \uC744 \uC4F0\uC138\uC694.");
2321
+ }
2322
+ return { resolved, identity };
2323
+ }
2324
+ function resolveClass(ctx, classToken) {
2325
+ const resolved = ctx.store.resolveToken(classToken);
2326
+ if (!resolved) {
2327
+ throw new UnknownTokenError("\uC54C \uC218 \uC5C6\uB294 \uC218\uC5C5\uBC18 \uD1A0\uD070\uC785\uB2C8\uB2E4. \uBA3C\uC800 list_classes \uB85C \uD1A0\uD070\uC744 \uD655\uC778\uD558\uC138\uC694.");
2328
+ }
2329
+ const identity = parseIdentity(resolved);
2330
+ if (identity.kind !== "class") {
2331
+ throw new UnknownTokenError("\uD559\uC0DD \uD1A0\uD070\uC744 \uC218\uC5C5\uBC18 \uD1A0\uD070 \uC790\uB9AC\uC5D0 \uC0AC\uC6A9\uD588\uC2B5\uB2C8\uB2E4. list_classes \uC758 classToken \uC744 \uC4F0\uC138\uC694.");
2332
+ }
2333
+ const cls = readTeachingClasses(ctx.dataDir).classes.find((c) => c.id === identity.classId);
2334
+ if (!cls)
2335
+ throw new UnknownTokenError("\uC218\uC5C5\uBC18\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4(\uC0AD\uC81C\uB418\uC5C8\uC744 \uC218 \uC788\uC74C).");
2336
+ return cls;
2337
+ }
2338
+ function listClasses(ctx) {
2339
+ const classes = readTeachingClasses(ctx.dataDir).classes;
2340
+ const views = classes.map((c) => ({
2341
+ classToken: ctx.store.getToken(makeClassIdentity(c.id), { prefix: "cls" }),
2342
+ subject: c.subject,
2343
+ name: c.name,
2344
+ studentCount: c.students.length
2345
+ }));
2346
+ ctx.audit.append({ tool: "list_classes", redactionStats: { students: classes.length } });
2347
+ return { count: views.length, classes: views };
2348
+ }
2349
+ function listStudents(ctx, args = {}) {
2350
+ if (args.classToken !== void 0) {
2351
+ const cls = resolveClass(ctx, args.classToken);
2352
+ const roster2 = cls.students.map((s) => ({
2353
+ studentNumber: s.number,
2354
+ token: ctx.store.getToken(makeTeachingStudentIdentity(cls.id, studentKey(s)), { prefix: "tcs" })
2355
+ }));
2356
+ ctx.audit.append({ tool: "list_students", redactionStats: { students: roster2.length } });
2357
+ return { count: roster2.length, students: roster2 };
2358
+ }
2359
+ const students = readStudents(ctx.dataDir);
2360
+ const roster = students.map((s) => {
2361
+ const token = ctx.store.getToken(s.id);
2362
+ return s.studentNumber === void 0 ? { token } : { studentNumber: s.studentNumber, token };
2363
+ });
2364
+ ctx.audit.append({ tool: "list_students", redactionStats: { students: students.length } });
2365
+ return { count: roster.length, students: roster };
2366
+ }
2367
+ function getSeating(ctx) {
2368
+ const seating = readSeating(ctx.dataDir);
2369
+ if (!seating)
2370
+ return null;
2371
+ let seated = 0;
2372
+ const seats = seating.seats.map((row) => row.map((cell) => {
2373
+ if (!cell)
2374
+ return null;
2375
+ seated += 1;
2376
+ return ctx.store.getToken(cell);
2377
+ }));
2378
+ ctx.audit.append({ tool: "get_seating", redactionStats: { students: seated } });
2379
+ return { rows: seating.rows, cols: seating.cols, seats };
2380
+ }
2381
+ function isYmd(v) {
2382
+ return typeof v === "string" && /^\d{8}$/.test(v);
2383
+ }
2384
+ function getMeals(ctx, args = {}) {
2385
+ const all = readManualMeals(ctx.dataDir);
2386
+ const from = isYmd(args.from) ? args.from : void 0;
2387
+ const to = isYmd(args.to) ? args.to : void 0;
2388
+ const meals = all.filter((m) => {
2389
+ if (from !== void 0 && m.date < from)
2390
+ return false;
2391
+ if (to !== void 0 && m.date > to)
2392
+ return false;
2393
+ return true;
2394
+ });
2395
+ ctx.audit.append({ tool: "get_meals", redactionStats: { items: meals.length } });
2396
+ return { count: meals.length, meals };
2397
+ }
2398
+ var CONTENT_GATE_NOTICE = "\uC81C\uBAA9\xB7\uC124\uBA85\xB7\uC7A5\uC18C \uB4F1 \uC790\uC720\uC11C\uC220\uC740 SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uB9C8\uC2A4\uD130 \uC2A4\uC704\uCE58\uAC00 \uCF1C\uC9C4 \uACBD\uC6B0\uC5D0\uB9CC \uB178\uCD9C\uB429\uB2C8\uB2E4(\uD604\uC7AC \uBBF8\uB178\uCD9C). \uB0A0\uC9DC\xB7\uAD50\uC2DC \uB4F1 \uBE44\uC2DD\uBCC4 \uBA54\uD0C0\uB9CC \uBC18\uD658\uD588\uC2B5\uB2C8\uB2E4.";
2399
+ var CONTENT_SHOWN_NOTICE = "\uC790\uC720\uC11C\uC220\uC774 \uD3EC\uD568\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4. \uD559\uC0DD \uC2E4\uBA85\xB7\uC5F0\uB77D\uCC98\xB7\uC0DD\uC77C\uC740 \uB9C8\uC2A4\uD0B9\uB418\uC9C0\uB9CC \uB9E5\uB77D\uC73C\uB85C \uC7AC\uC2DD\uBCC4\uB420 \uC218 \uC788\uC73C\uBBC0\uB85C \uAD50\uC0AC \uAC80\uD1A0\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.";
2400
+ function sumDeid(stats) {
2401
+ return stats.names + stats.phones + stats.rrns + stats.birthDates + stats.studentNumbers;
2402
+ }
2403
+ function buildFullRoster(ctx) {
2404
+ const entries = [];
2405
+ for (const s of readStudents(ctx.dataDir)) {
2406
+ if (!s.name)
2407
+ continue;
2408
+ const e = {
2409
+ token: ctx.store.getToken(s.id),
2410
+ names: [s.name]
2411
+ };
2412
+ if (s.studentNumber !== void 0)
2413
+ e.studentNumber = s.studentNumber;
2414
+ entries.push(e);
2415
+ }
2416
+ for (const c of readTeachingClasses(ctx.dataDir).classes) {
2417
+ for (const st of c.students) {
2418
+ if (!st.name)
2419
+ continue;
2420
+ const e = {
2421
+ token: ctx.store.getToken(makeTeachingStudentIdentity(c.id, studentKey(st)), { prefix: "tcs" }),
2422
+ names: [st.name]
2423
+ };
2424
+ if (st.number !== void 0)
2425
+ e.studentNumber = st.number;
2426
+ entries.push(e);
2427
+ }
2428
+ }
2429
+ return entries;
2430
+ }
2431
+ function isYmdDash(v) {
2432
+ return typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
2433
+ }
2434
+ function safeEventView(e) {
2435
+ const v = { date: e.date };
2436
+ const copy = (k) => {
2437
+ const val = e[k];
2438
+ if (val !== void 0)
2439
+ v[k] = val;
2440
+ };
2441
+ copy("endDate");
2442
+ copy("category");
2443
+ copy("time");
2444
+ copy("startTime");
2445
+ copy("endTime");
2446
+ copy("period");
2447
+ copy("periodEnd");
2448
+ copy("recurrence");
2449
+ copy("isDDay");
2450
+ return v;
2451
+ }
2452
+ function getEvents(ctx, args = {}) {
2453
+ const all = readEvents(ctx.dataDir);
2454
+ const from = isYmdDash(args.from) ? args.from : void 0;
2455
+ const to = isYmdDash(args.to) ? args.to : void 0;
2456
+ const filtered = all.filter((e) => {
2457
+ const end = e.endDate ?? e.date;
2458
+ if (from !== void 0 && end < from)
2459
+ return false;
2460
+ if (to !== void 0 && e.date > to)
2461
+ return false;
2462
+ return true;
2463
+ });
2464
+ const contentOn = isContentExposureEnabled();
2465
+ const roster = contentOn ? buildFullRoster(ctx) : [];
2466
+ let masked = 0;
2467
+ const events = filtered.map((e) => {
2468
+ const v = safeEventView(e);
2469
+ if (contentOn) {
2470
+ const t = deidentify(e.title, roster);
2471
+ masked += sumDeid(t.stats);
2472
+ v["title"] = t.text;
2473
+ if (e.description !== void 0) {
2474
+ const d = deidentify(e.description, roster);
2475
+ masked += sumDeid(d.stats);
2476
+ v["description"] = d.text;
2477
+ }
2478
+ if (e.location !== void 0) {
2479
+ const l = deidentify(e.location, roster);
2480
+ masked += sumDeid(l.stats);
2481
+ v["location"] = l.text;
2482
+ }
2483
+ }
2484
+ return v;
2485
+ });
2486
+ ctx.audit.append({ tool: "get_events", redactionStats: { items: events.length, names: masked } });
2487
+ return {
2488
+ count: events.length,
2489
+ contentIncluded: contentOn,
2490
+ notice: contentOn ? CONTENT_SHOWN_NOTICE : CONTENT_GATE_NOTICE,
2491
+ events
2492
+ };
2493
+ }
2494
+ function getDdays(ctx) {
2495
+ const all = readDdays(ctx.dataDir);
2496
+ const contentOn = isContentExposureEnabled();
2497
+ const roster = contentOn ? buildFullRoster(ctx) : [];
2498
+ let masked = 0;
2499
+ const ddays = all.map((d) => {
2500
+ const v = { date: d.date };
2501
+ if (d.emoji !== void 0)
2502
+ v["emoji"] = d.emoji;
2503
+ if (d.color !== void 0)
2504
+ v["color"] = d.color;
2505
+ if (d.pinned !== void 0)
2506
+ v["pinned"] = d.pinned;
2507
+ if (contentOn) {
2508
+ const t = deidentify(d.title, roster);
2509
+ masked += sumDeid(t.stats);
2510
+ v["title"] = t.text;
2511
+ }
2512
+ return v;
2513
+ });
2514
+ ctx.audit.append({ tool: "get_ddays", redactionStats: { items: ddays.length, names: masked } });
2515
+ return {
2516
+ count: ddays.length,
2517
+ contentIncluded: contentOn,
2518
+ notice: contentOn ? CONTENT_SHOWN_NOTICE : CONTENT_GATE_NOTICE,
2519
+ ddays
2520
+ };
2521
+ }
2522
+ function safeTodoView(t) {
2523
+ const v = {
2524
+ status: effectiveTodoStatus(t),
2525
+ completed: t.completed
2526
+ };
2527
+ const copy = (k) => {
2528
+ const val = t[k];
2529
+ if (val !== void 0)
2530
+ v[k] = val;
2531
+ };
2532
+ copy("dueDate");
2533
+ copy("startDate");
2534
+ copy("time");
2535
+ copy("priority");
2536
+ copy("category");
2537
+ copy("recurrence");
2538
+ copy("archivedAt");
2539
+ copy("subTaskCount");
2540
+ copy("subTaskDone");
2541
+ return v;
2542
+ }
2543
+ function getTodos(ctx, args = {}) {
2544
+ const all = readTodos(ctx.dataDir);
2545
+ const dueBefore = isYmdDash(args.dueBefore) ? args.dueBefore : void 0;
2546
+ const statusFilter = args.status === "todo" || args.status === "inProgress" || args.status === "done" ? args.status : void 0;
2547
+ const filtered = all.filter((t) => {
2548
+ if (!args.includeArchived && t.archivedAt !== void 0)
2549
+ return false;
2550
+ if (statusFilter !== void 0 && effectiveTodoStatus(t) !== statusFilter)
2551
+ return false;
2552
+ if (dueBefore !== void 0 && (t.dueDate === void 0 || t.dueDate > dueBefore))
2553
+ return false;
2554
+ return true;
2555
+ });
2556
+ const contentOn = isContentExposureEnabled();
2557
+ const roster = contentOn ? buildFullRoster(ctx) : [];
2558
+ let masked = 0;
2559
+ const todos = filtered.map((t) => {
2560
+ const v = safeTodoView(t);
2561
+ if (contentOn) {
2562
+ const tx = deidentify(t.text, roster);
2563
+ masked += sumDeid(tx.stats);
2564
+ v["text"] = tx.text;
2565
+ if (t.notes !== void 0) {
2566
+ const n = deidentify(t.notes, roster);
2567
+ masked += sumDeid(n.stats);
2568
+ v["notes"] = n.text;
2569
+ }
2570
+ }
2571
+ return v;
2572
+ });
2573
+ ctx.audit.append({ tool: "get_todos", redactionStats: { items: todos.length, names: masked } });
2574
+ return {
2575
+ count: todos.length,
2576
+ contentIncluded: contentOn,
2577
+ notice: contentOn ? CONTENT_SHOWN_NOTICE : CONTENT_GATE_NOTICE,
2578
+ todos
2579
+ };
2580
+ }
2581
+ function getSchedule(ctx, args) {
2582
+ const kind = args.kind;
2583
+ if (kind === "class") {
2584
+ const slots2 = readClassSchedule(ctx.dataDir);
2585
+ ctx.audit.append({ tool: "get_schedule", redactionStats: { items: slots2.length } });
2586
+ return { kind, count: slots2.length, slots: slots2 };
2587
+ }
2588
+ if (kind === "teacher") {
2589
+ const slots2 = readTeacherSchedule(ctx.dataDir);
2590
+ ctx.audit.append({ tool: "get_schedule", redactionStats: { items: slots2.length } });
2591
+ return { kind, count: slots2.length, slots: slots2 };
2592
+ }
2593
+ const all = readTimetableOverrides(ctx.dataDir);
2594
+ const contentOn = isContentExposureEnabled();
2595
+ const roster = contentOn ? buildFullRoster(ctx) : [];
2596
+ let masked = 0;
2597
+ const slots = all.map((o) => {
2598
+ const v = { date: o.date, period: o.period };
2599
+ if (o.subject !== void 0)
2600
+ v["subject"] = o.subject;
2601
+ if (o.classroom !== void 0)
2602
+ v["classroom"] = o.classroom;
2603
+ if (o.kind !== void 0)
2604
+ v["kind"] = o.kind;
2605
+ if (o.scope !== void 0)
2606
+ v["scope"] = o.scope;
2607
+ if (o.substituteTeacher !== void 0)
2608
+ v["substituteTeacher"] = o.substituteTeacher;
2609
+ if (contentOn && o.reason !== void 0) {
2610
+ const r = deidentify(o.reason, roster);
2611
+ masked += sumDeid(r.stats);
2612
+ v["reason"] = r.text;
2613
+ }
2614
+ return v;
2615
+ });
2616
+ ctx.audit.append({ tool: "get_schedule", redactionStats: { items: slots.length, names: masked } });
2617
+ return {
2618
+ kind,
2619
+ count: slots.length,
2620
+ contentIncluded: contentOn,
2621
+ notice: contentOn ? CONTENT_SHOWN_NOTICE : CONTENT_GATE_NOTICE,
2622
+ slots
2623
+ };
2624
+ }
2625
+ function getNotes(ctx) {
2626
+ const notebooks = readNotebooks(ctx.dataDir).filter((n) => !n.archived);
2627
+ const sections = readNoteSections(ctx.dataDir);
2628
+ const pages = readNotePages(ctx.dataDir);
2629
+ const counts = { notebooks: notebooks.length, sections: sections.length, pages: pages.length };
2630
+ if (!isContentExposureEnabled()) {
2631
+ ctx.audit.append({ tool: "get_notes", redactionStats: { items: pages.length } });
2632
+ return { contentIncluded: false, notice: CONTENT_GATE_NOTICE, counts };
2633
+ }
2634
+ const { deid, masked } = makeDeider(buildFullRoster(ctx));
2635
+ const pagesBySection = /* @__PURE__ */ new Map();
2636
+ for (const p of pages) {
2637
+ const view = {
2638
+ title: deid(p.title),
2639
+ tags: p.tags.map(deid),
2640
+ pinned: p.pinned,
2641
+ ...p.updatedAt !== void 0 ? { updatedAt: p.updatedAt } : {}
2642
+ };
2643
+ const arr = pagesBySection.get(p.sectionId) ?? [];
2644
+ arr.push(view);
2645
+ pagesBySection.set(p.sectionId, arr);
2646
+ }
2647
+ const sectionsByNotebook = /* @__PURE__ */ new Map();
2648
+ for (const s of [...sections].sort((a, b) => a.order - b.order)) {
2649
+ const arr = sectionsByNotebook.get(s.notebookId) ?? [];
2650
+ arr.push({ title: deid(s.title), pages: pagesBySection.get(s.id) ?? [] });
2651
+ sectionsByNotebook.set(s.notebookId, arr);
2652
+ }
2653
+ const notebookViews = [...notebooks].sort((a, b) => a.order - b.order).map((n) => ({ title: deid(n.title), sections: sectionsByNotebook.get(n.id) ?? [] }));
2654
+ ctx.audit.append({ tool: "get_notes", redactionStats: { items: pages.length, names: masked() } });
2655
+ return { contentIncluded: true, notice: CONTENT_SHOWN_NOTICE, counts, notebooks: notebookViews };
2656
+ }
2657
+ function getMemos(ctx) {
2658
+ const memos = readMemos(ctx.dataDir).filter((m) => !m.archived);
2659
+ if (!isContentExposureEnabled()) {
2660
+ ctx.audit.append({ tool: "get_memos", redactionStats: { items: memos.length } });
2661
+ return { contentIncluded: false, notice: CONTENT_GATE_NOTICE, count: memos.length };
2662
+ }
2663
+ const { deid, masked } = makeDeider(buildFullRoster(ctx));
2664
+ const views = memos.map((m) => ({
2665
+ text: deid(m.text),
2666
+ ...m.color !== void 0 ? { color: m.color } : {}
2667
+ }));
2668
+ ctx.audit.append({ tool: "get_memos", redactionStats: { items: memos.length, names: masked() } });
2669
+ return { contentIncluded: true, notice: CONTENT_SHOWN_NOTICE, count: memos.length, memos: views };
2670
+ }
2671
+ function getBookmarks(ctx) {
2672
+ const { groups, bookmarks } = readBookmarks(ctx.dataDir);
2673
+ const activeGroups = groups.filter((g) => !g.archived);
2674
+ if (!isContentExposureEnabled()) {
2675
+ ctx.audit.append({ tool: "get_bookmarks", redactionStats: { items: bookmarks.length } });
2676
+ return { contentIncluded: false, notice: CONTENT_GATE_NOTICE, count: bookmarks.length };
2677
+ }
2678
+ const { deid, masked } = makeDeider(buildFullRoster(ctx));
2679
+ const byGroup = /* @__PURE__ */ new Map();
2680
+ for (const b of bookmarks) {
2681
+ const arr = byGroup.get(b.groupId) ?? [];
2682
+ arr.push({ name: deid(b.name), url: deid(stripUrlSecrets(b.url)) });
2683
+ byGroup.set(b.groupId, arr);
2684
+ }
2685
+ const groupViews = [...activeGroups].sort((a, b) => a.order - b.order).map((g) => ({ name: deid(g.name), bookmarks: byGroup.get(g.id) ?? [] }));
2686
+ ctx.audit.append({ tool: "get_bookmarks", redactionStats: { items: bookmarks.length, names: masked() } });
2687
+ return { contentIncluded: true, notice: CONTENT_SHOWN_NOTICE, count: bookmarks.length, groups: groupViews };
2688
+ }
2689
+ async function addObservation(ctx, args) {
2690
+ assertWriteEnabled();
2691
+ const { identity } = resolveStudentTarget(ctx, args.studentToken);
2692
+ const input = { content: args.content };
2693
+ if (identity.kind === "teaching") {
2694
+ input["studentId"] = identity.studentKey;
2695
+ input["classId"] = identity.classId;
2696
+ } else {
2697
+ input["studentId"] = identity.studentId;
2698
+ }
2699
+ if (args.tags !== void 0)
2700
+ input["tags"] = args.tags;
2701
+ if (args.date !== void 0)
2702
+ input["date"] = args.date;
2703
+ if (args.idempotencyKey !== void 0)
2704
+ input["clientKey"] = args.idempotencyKey;
2705
+ const record = await appendObservation(ctx.dataDir, input);
2706
+ ctx.audit.append({ tool: "add_observation", recordIds: [record.id], redactionStats: { observations: 1 } });
2707
+ return {
2708
+ ok: true,
2709
+ token: args.studentToken,
2710
+ observationRef: ctx.audit.hashRecordId(record.id),
2711
+ date: record.date,
2712
+ contentLength: record.content.length
2713
+ };
2714
+ }
2715
+ var SENSITIVE_NOTICE = "\uB3D9\uC758 \uD558\uC5D0 \uB178\uCD9C\uB41C \uBBFC\uAC10 \uADFC\uAC70 \uC6D0\uBB38\uC785\uB2C8\uB2E4. \uC9C1\uC811 \uC2DD\uBCC4\uC790(\uC2E4\uBA85/\uC5F0\uB77D\uCC98/\uC0DD\uC77C/\uD559\uBC88)\uB9CC \uB9C8\uC2A4\uD0B9\uB418\uBA70, \uC8FC\uC18C\xB7\uAC00\uC871\uAD00\uACC4\xB7\uAC74\uAC15\xB7\uC0C1\uB2F4\xB7\uD2B9\uC815 \uD65C\uB3D9/\uC7A5\uC18C \uB4F1 \uB9E5\uB77D\uC73C\uB85C \uC7AC\uC2DD\uBCC4\uB420 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uC775\uBA85\uD654\uB41C \uB370\uC774\uD130\uAC00 \uC544\uB2C8\uBBC0\uB85C \uAD50\uC0AC \uAC80\uD1A0\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.";
2716
+ function getObservations(ctx, args) {
2717
+ const { resolved, identity } = resolveStudentTarget(ctx, args.studentToken);
2718
+ const access = assertContentAccess({
2719
+ studentId: resolved,
2720
+ purpose: OBSERVATION_READ_PURPOSE,
2721
+ consent: ctx.consent
2722
+ });
2723
+ const roster = rosterForIdentity(ctx.dataDir, identity, ctx.store);
2724
+ const query = {};
2725
+ if (args.from !== void 0)
2726
+ query.from = args.from;
2727
+ if (args.to !== void 0)
2728
+ query.to = args.to;
2729
+ const records = getObservationsForIdentity(ctx.dataDir, identity, query).filter((r) => isObservationDateAllowed(access, r.date));
2730
+ let masked = 0;
2731
+ const observations = records.map((r) => {
2732
+ const { text, stats } = deidentify(r.content, roster);
2733
+ masked += stats.names + stats.phones + stats.rrns + stats.birthDates + stats.studentNumbers;
2734
+ const tags = r.tags.map((t) => deidentify(t, roster).text);
2735
+ return { observationId: ctx.store.getToken(makeObservationIdentity(r.id), { prefix: "obs" }), date: r.date, tags, content: text };
2736
+ });
2737
+ ctx.audit.append({
2738
+ tool: "get_observations",
2739
+ ...access.via === "consent" ? { consentId: access.consents.map((c) => c.id).join(",") } : {},
2740
+ recordIds: records.map((r) => r.id),
2741
+ redactionStats: { observations: records.length, names: masked }
2742
+ });
2743
+ return { count: observations.length, observations, notice: SENSITIVE_NOTICE };
2744
+ }
2745
+ function checkRecordDraft(ctx, args) {
2746
+ const { identity } = resolveStudentTarget(ctx, args.studentToken);
2747
+ const query = {};
2748
+ if (args.from !== void 0)
2749
+ query.from = args.from;
2750
+ if (args.to !== void 0)
2751
+ query.to = args.to;
2752
+ const records = getObservationsForIdentity(ctx.dataDir, identity, query);
2753
+ const tokenized = records.map((o) => ({ id: ctx.store.getToken(makeObservationIdentity(o.id), { prefix: "obs" }), content: o.content }));
2754
+ const rulePack = {};
2755
+ if (args.level !== void 0)
2756
+ rulePack.level = args.level;
2757
+ if (args.year !== void 0)
2758
+ rulePack.year = args.year;
2759
+ const report = checkGrounding(args.claims, tokenized, rulePack);
2760
+ ctx.audit.append({
2761
+ tool: "check_record_draft",
2762
+ recordIds: records.map((o) => o.id),
2763
+ redactionStats: { observations: records.length },
2764
+ validatorResult: report.flaggedCount === 0 ? "no_flags" : `flagged:${report.flaggedCount}`,
2765
+ rulePackVersion: report.rulePackVersion
2766
+ });
2767
+ return report;
2768
+ }
2769
+ var ASSESSMENT_NOTICE = "\uC810\uC218\xB7\uC11D\uCC28\xB7\uD658\uC0B0\uC810\uC740 \uC81C\uC678\uB41C \uC9C8\uC801 \uC815\uBCF4\uC785\uB2C8\uB2E4(\uC0DD\uAE30\uBD80 \uC785\uB825 \uAE08\uC9C0 \uD56D\uBAA9). \uB3C4\uB2EC \uC218\uC900\xB7\uC131\uCDE8\uB3C4\xB7\uBA54\uBAA8\uB294 \uC11C\uC220\uD615 \uC5ED\uB7C9\xB7\uD0DC\uB3C4 \uD45C\uD604\uC758 \uADFC\uAC70\uB85C\uB9CC \uD65C\uC6A9\uD558\uACE0, \uC810\uC218/\uC11D\uCC28\uB97C \uC0DD\uAE30\uBD80\uC5D0 \uC801\uC9C0 \uB9C8\uC138\uC694. \uAD50\uC0AC \uCD5C\uC885 \uAC80\uD1A0 \uD544\uC694.";
2770
+ function resolveTeachingTarget(ctx, token) {
2771
+ const { resolved, identity } = resolveStudentTarget(ctx, token);
2772
+ if (identity.kind !== "teaching") {
2773
+ throw new UnknownTokenError("\uC218\uD589\uD3C9\uAC00\xB7\uC131\uC801\uC740 \uC218\uC5C5\uBC18 \uD559\uC0DD \uD1A0\uD070\uC73C\uB85C \uC870\uD68C\uD558\uC138\uC694. list_classes \u2192 list_students(classToken) \uC758 token \uC744 \uC4F0\uC138\uC694.");
2774
+ }
2775
+ return { classId: identity.classId, studentKey: identity.studentKey, resolved };
2776
+ }
2777
+ function getPerformanceFeedback(ctx, args) {
2778
+ const { classId, studentKey: studentKey2, resolved } = resolveTeachingTarget(ctx, args.studentToken);
2779
+ const access = assertContentAccess({ studentId: resolved, purpose: OBSERVATION_READ_PURPOSE, consent: ctx.consent });
2780
+ const roster = rosterForIdentity(ctx.dataDir, { kind: "teaching", classId, studentKey: studentKey2 }, ctx.store);
2781
+ const items = getRubricFeedback(ctx.dataDir, classId, studentKey2, (s) => deidentify(s, roster).text);
2782
+ ctx.audit.append({
2783
+ tool: "get_performance_feedback",
2784
+ ...access.via === "consent" ? { consentId: access.consents.map((c) => c.id).join(",") } : {},
2785
+ redactionStats: { observations: items.length }
2786
+ });
2787
+ return { count: items.length, items, notice: ASSESSMENT_NOTICE };
2788
+ }
2789
+ function getGradeSummaryTool(ctx, args) {
2790
+ const { classId, studentKey: studentKey2, resolved } = resolveTeachingTarget(ctx, args.studentToken);
2791
+ const access = assertContentAccess({ studentId: resolved, purpose: OBSERVATION_READ_PURPOSE, consent: ctx.consent });
2792
+ const roster = rosterForIdentity(ctx.dataDir, { kind: "teaching", classId, studentKey: studentKey2 }, ctx.store);
2793
+ const summary = getGradeSummary(ctx.dataDir, classId, studentKey2, (s) => deidentify(s, roster).text);
2794
+ ctx.audit.append({
2795
+ tool: "get_grade_summary",
2796
+ ...access.via === "consent" ? { consentId: access.consents.map((c) => c.id).join(",") } : {},
2797
+ redactionStats: { observations: summary.assessments.length }
2798
+ });
2799
+ return { ...summary, notice: ASSESSMENT_NOTICE };
2800
+ }
2801
+ function getRecordGuidelines(args = {}) {
2802
+ const rulePack = {};
2803
+ if (args.level !== void 0)
2804
+ rulePack.level = args.level;
2805
+ if (args.year !== void 0)
2806
+ rulePack.year = args.year;
2807
+ return recordGuidelines(rulePack);
2808
+ }
2809
+
2810
+ // ../packages/mcp/dist/server.js
2811
+ async function runTool(label, produce) {
2812
+ try {
2813
+ const value = await produce();
2814
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
2815
+ } catch (err) {
2816
+ process.stderr.write(`[ssampin-mcp] tool '${label}' \uC2E4\uD328: ${err instanceof Error ? err.name : "Error"}
2817
+ `);
2818
+ return { content: [{ type: "text", text: "\uB3C4\uAD6C \uC2E4\uD589 \uC911 \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4." }], isError: true };
2819
+ }
2820
+ }
2821
+ function createSsampinMcpServer(opts = {}) {
2822
+ const ctx = createContext(opts.dataDir);
2823
+ const server = new McpServer({ name: "ssampin-ai-bridge", version: "0.0.0" });
2824
+ server.registerTool("list_classes", {
2825
+ title: "\uC218\uC5C5\uBC18(\uAD50\uACFC\uBC18) \uBAA9\uB85D",
2826
+ description: "\uB2F4\uC784 \uD559\uAE09 \uC678\uC5D0 \uAD50\uC0AC\uAC00 \uAC00\uB974\uCE58\uB294 \uC218\uC5C5\uBC18(\uAD50\uACFC\uBC18) \uBAA9\uB85D\uC744 \uACFC\uBAA9\xB7\uBC18\uC774\uB984 + \uBD88\uD22C\uBA85 classToken \uC73C\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4. \uD559\uC0DD \uC2E4\uBA85\xB7\uBA54\uBAA8\uB294 \uD3EC\uD568\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uD2B9\uC815 \uC218\uC5C5\uBC18\uC758 \uD559\uC0DD\uC744 \uBCF4\uB824\uBA74 \uC774 classToken \uC744 list_students \uC758 classToken \uC778\uC790\uB85C \uB118\uAE30\uC138\uC694. \uC77D\uAE30 \uC804\uC6A9.",
2827
+ inputSchema: {},
2828
+ annotations: { readOnlyHint: true }
2829
+ }, async () => runTool("list_classes", () => listClasses(ctx)));
2830
+ server.registerTool("list_students", {
2831
+ title: "\uD559\uC0DD \uBA85\uB2E8(\uBC88\uD638+\uAC00\uBA85)",
2832
+ description: '\uD559\uC0DD \uBA85\uB2E8\uC744 "\uBC88\uD638 + \uBD88\uD22C\uBA85 \uD1A0\uD070"\uC73C\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4. classToken \uBBF8\uC9C0\uC815 \uC2DC \uB2F4\uC784 \uD559\uAE09(\uD559\uBC88), classToken \uC9C0\uC815 \uC2DC \uD574\uB2F9 \uC218\uC5C5\uBC18(\uBC18 \uB0B4 \uBC88\uD638)\uC744 \uBC18\uD658\uD569\uB2C8\uB2E4. \uAD50\uC0AC\uAC00 \uD1A0\uD070\uC744 \uC678\uC6B8 \uC218 \uC5C6\uC73C\uBBC0\uB85C \uBA85\uB2E8\uC5D0\uB9CC \uBC88\uD638\uB97C \uB178\uCD9C\uD558\uBA70, \uC2E4\uBA85\xB7\uC5F0\uB77D\uCC98\xB7\uC0DD\uB144\uC6D4\uC77C\xB7\uBA54\uBAA8\uB294 \uD3EC\uD568\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uBC88\uD638\uB85C \uD559\uC0DD\uC744 \uCC3E\uC544 \uAC19\uC740 \uD589\uC758 token \uC73C\uB85C add_observation/get_observations \uD558\uC138\uC694. \uC77D\uAE30 \uC804\uC6A9.',
2833
+ inputSchema: {
2834
+ classToken: z.string().optional().describe("list_classes \uAC00 \uBC18\uD658\uD55C \uC218\uC5C5\uBC18 \uD1A0\uD070(\uBBF8\uC9C0\uC815 \uC2DC \uB2F4\uC784 \uD559\uAE09)")
2835
+ },
2836
+ annotations: { readOnlyHint: true }
2837
+ }, async (args) => runTool("list_students", () => listStudents(ctx, args)));
2838
+ server.registerTool("get_seating", {
2839
+ title: "\uC88C\uC11D \uBC30\uCE58(\uAC00\uBA85)",
2840
+ description: "\uD604\uC7AC \uC88C\uC11D \uBC30\uCE58\uB97C \uD559\uC0DD \uD1A0\uD070 \uACA9\uC790\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4(\uBE48 \uC88C\uC11D\uC740 null). \uC2E4\uBA85 \uBBF8\uD3EC\uD568, \uC77D\uAE30 \uC804\uC6A9.",
2841
+ inputSchema: {},
2842
+ annotations: { readOnlyHint: true }
2843
+ }, async () => runTool("get_seating", () => getSeating(ctx)));
2844
+ server.registerTool("get_meals", {
2845
+ title: "\uAE09\uC2DD \uC2DD\uB2E8(\uBA54\uB274\xB7\uC54C\uB808\uB974\uAE30)",
2846
+ description: "\uC218\uB3D9 \uC785\uB825 \uAE09\uC2DD \uC2DD\uB2E8\uC744 \uB0A0\uC9DC\xB7\uB07C\uB2C8\uBCC4 \uBA54\uB274\uC640 \uC54C\uB808\uB974\uAE30 \uCF54\uB4DC\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4. \uD559\uC0DD \uAC1C\uC778\uC815\uBCF4\uB294 \uD3EC\uD568\uD558\uC9C0 \uC54A\uC73C\uBA70(\uBA54\uB274 \uC815\uBCF4\uB9CC), \uB3D9\uC758\xB7\uAC8C\uC774\uD2B8 \uC5C6\uC774 \uC77D\uC744 \uC218 \uC788\uC2B5\uB2C8\uB2E4. from/to \uB294 YYYYMMDD(8\uC790\uB9AC). \uC77D\uAE30 \uC804\uC6A9.",
2847
+ inputSchema: {
2848
+ from: z.string().regex(/^\d{8}$/).optional().describe("\uC2DC\uC791\uC77C YYYYMMDD(\uD3EC\uD568)"),
2849
+ to: z.string().regex(/^\d{8}$/).optional().describe("\uC885\uB8CC\uC77C YYYYMMDD(\uD3EC\uD568)")
2850
+ },
2851
+ annotations: { readOnlyHint: true }
2852
+ }, async (args) => runTool("get_meals", () => getMeals(ctx, args)));
2853
+ server.registerTool("get_events", {
2854
+ title: "\uD559\uC0AC\xB7\uD559\uAE09 \uC77C\uC815",
2855
+ description: "\uC77C\uC815\uC744 \uB0A0\uC9DC\xB7\uAD50\uC2DC\xB7\uCE74\uD14C\uACE0\uB9AC \uB4F1 \uBE44\uC2DD\uBCC4 \uBA54\uD0C0\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4. \uC81C\uBAA9\xB7\uC124\uBA85\xB7\uC7A5\uC18C \uB4F1 \uC790\uC720\uC11C\uC220\uC740 SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uB9C8\uC2A4\uD130 \uC2A4\uC704\uCE58\uAC00 \uCF1C\uC9C4 \uACBD\uC6B0\uC5D0\uB9CC \uD559\uC0DD \uC2E4\uBA85 \uD0C8\uC2DD\uBCC4 \uD6C4 \uD3EC\uD568\uB429\uB2C8\uB2E4(\uB9E5\uB77D \uC7AC\uC2DD\uBCC4 \uAC00\uB2A5 \u2014 \uAD50\uC0AC \uAC80\uD1A0 \uD544\uC694). from/to \uB294 YYYY-MM-DD. \uC77D\uAE30 \uC804\uC6A9.",
2856
+ inputSchema: {
2857
+ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("\uC2DC\uC791\uC77C YYYY-MM-DD(\uD3EC\uD568)"),
2858
+ to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("\uC885\uB8CC\uC77C YYYY-MM-DD(\uD3EC\uD568)")
2859
+ },
2860
+ annotations: { readOnlyHint: true }
2861
+ }, async (args) => runTool("get_events", () => getEvents(ctx, args)));
2862
+ server.registerTool("get_ddays", {
2863
+ title: "\uB514\uB370\uC774(D-Day) \uBAA9\uB85D",
2864
+ description: "\uB514\uB370\uC774 \uD56D\uBAA9\uC744 \uBAA9\uD45C\uC77C\xB7\uC774\uBAA8\uC9C0\xB7\uC0C9\uC0C1\uC73C\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4. \uC81C\uBAA9(\uC790\uC720\uC11C\uC220)\uC740 SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uC77C \uB54C\uB9CC \uD0C8\uC2DD\uBCC4 \uD6C4 \uD3EC\uD568\uB429\uB2C8\uB2E4. \uC77D\uAE30 \uC804\uC6A9.",
2865
+ inputSchema: {},
2866
+ annotations: { readOnlyHint: true }
2867
+ }, async () => runTool("get_ddays", () => getDdays(ctx)));
2868
+ server.registerTool("get_todos", {
2869
+ title: "\uD560\uC77C(To-do) \uBAA9\uB85D",
2870
+ description: "\uD560\uC77C\uC744 \uC0C1\uD0DC\xB7\uB9C8\uAC10\uC77C\xB7\uC6B0\uC120\uC21C\uC704\xB7\uCE74\uD14C\uACE0\uB9AC \uB4F1 \uBE44\uC2DD\uBCC4 \uBA54\uD0C0\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4. \uD560\uC77C \uB0B4\uC6A9(text)\xB7\uBA54\uBAA8(notes)\uB294 SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uB9C8\uC2A4\uD130 \uC2A4\uC704\uCE58\uAC00 \uCF1C\uC9C4 \uACBD\uC6B0\uC5D0\uB9CC \uD559\uC0DD \uC2E4\uBA85 \uD0C8\uC2DD\uBCC4 \uD6C4 \uD3EC\uD568\uB429\uB2C8\uB2E4. status(todo|inProgress|done)\uB85C \uC0C1\uD0DC \uD544\uD130, dueBefore(YYYY-MM-DD)\uB85C \uB9C8\uAC10 \uC784\uBC15 \uD544\uD130. \uC544\uCE74\uC774\uBE0C \uD56D\uBAA9\uC740 \uAE30\uBCF8 \uC81C\uC678. \uC77D\uAE30 \uC804\uC6A9.",
2871
+ inputSchema: {
2872
+ status: z.enum(["todo", "inProgress", "done"]).optional().describe("\uC0C1\uD0DC \uD544\uD130"),
2873
+ dueBefore: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("\uC774 \uB0A0\uC9DC \uC774\uC804(\uD3EC\uD568) \uB9C8\uAC10\uB9CC"),
2874
+ includeArchived: z.boolean().optional().describe("\uC544\uCE74\uC774\uBE0C \uD3EC\uD568(\uAE30\uBCF8 false)")
2875
+ },
2876
+ annotations: { readOnlyHint: true }
2877
+ }, async (args) => runTool("get_todos", () => getTodos(ctx, args)));
2878
+ server.registerTool("get_schedule", {
2879
+ title: "\uC2DC\uAC04\uD45C\xB7\uC77C\uACFC",
2880
+ description: "\uC2DC\uAC04\uD45C\uB97C \uC885\uB958\uBCC4\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4. kind=class(\uC6B0\uB9AC \uBC18 \uC2DC\uAC04\uD45C: \uC694\uC77C\xB7\uAD50\uC2DC\xB7\uACFC\uBAA9\xB7\uAD50\uC0AC), teacher(\uB0B4 \uC2DC\uAC04\uD45C: \uC694\uC77C\xB7\uAD50\uC2DC\xB7\uACFC\uBAA9\xB7\uAD50\uC2E4), overrides(\uBCC0\uB3D9 \uC2DC\uAC04\uD45C: \uB0A0\uC9DC\xB7\uAD50\uC2DC\xB7\uACFC\uBAA9\xB7\uC885\uB958\xB7\uBCF4\uAC15\uAD50\uC0AC). class/teacher \uB294 \uAC8C\uC774\uD2B8 \uC5C6\uC774 \uC77D\uD788\uBA70, overrides \uC758 \uBCC0\uACBD \uC0AC\uC720(reason)\uB9CC SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uC77C \uB54C \uD0C8\uC2DD\uBCC4 \uD6C4 \uD3EC\uD568\uB429\uB2C8\uB2E4. \uC77D\uAE30 \uC804\uC6A9.",
2881
+ inputSchema: {
2882
+ kind: z.enum(["class", "teacher", "overrides"]).describe("class=\uC6B0\uB9AC \uBC18 | teacher=\uB0B4 \uC2DC\uAC04\uD45C | overrides=\uBCC0\uB3D9")
2883
+ },
2884
+ annotations: { readOnlyHint: true }
2885
+ }, async (args) => runTool("get_schedule", () => getSchedule(ctx, args)));
2886
+ server.registerTool("get_notes", {
2887
+ title: "\uB178\uD2B8 \uAD6C\uC870(\uB178\uD2B8\uBD81\xB7\uC139\uC158\xB7\uD398\uC774\uC9C0 \uC81C\uBAA9)",
2888
+ description: "\uB178\uD2B8\uC758 \uAD6C\uC870(\uB178\uD2B8\uBD81\u2192\uC139\uC158\u2192\uD398\uC774\uC9C0)\uC640 \uC81C\uBAA9\xB7\uD0DC\uADF8\uB97C \uBC18\uD658\uD569\uB2C8\uB2E4. \uBCF8\uBB38\uC740 \uD3EC\uD568\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC81C\uBAA9\xB7\uD0DC\uADF8\uB294 \uC790\uC720\uC11C\uC220\uC774\uB77C SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uB9C8\uC2A4\uD130 \uC2A4\uC704\uCE58\uAC00 \uCF1C\uC9C4 \uACBD\uC6B0\uC5D0\uB9CC \uD559\uC0DD \uC2E4\uBA85 \uD0C8\uC2DD\uBCC4 \uD6C4 \uB178\uCD9C\uB418\uACE0, \uAEBC\uC838 \uC788\uC73C\uBA74 \uAC1C\uC218\uB9CC \uBC18\uD658\uD569\uB2C8\uB2E4. \uC77D\uAE30 \uC804\uC6A9.",
2889
+ inputSchema: {},
2890
+ annotations: { readOnlyHint: true }
2891
+ }, async () => runTool("get_notes", () => getNotes(ctx)));
2892
+ server.registerTool("get_memos", {
2893
+ title: "\uD3EC\uC2A4\uD2B8\uC787 \uBA54\uBAA8",
2894
+ description: "\uD3EC\uC2A4\uD2B8\uC787 \uBA54\uBAA8\uB97C \uBC18\uD658\uD569\uB2C8\uB2E4(\uC544\uCE74\uC774\uBE0C \uC81C\uC678). \uBA54\uBAA8 \uBCF8\uBB38\uC740 SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uC77C \uB54C\uB9CC \uD559\uC0DD \uC2E4\uBA85 \uD0C8\uC2DD\uBCC4 \uD6C4 \uB178\uCD9C\uB418\uACE0, \uAEBC\uC838 \uC788\uC73C\uBA74 \uAC1C\uC218\uB9CC \uBC18\uD658\uD569\uB2C8\uB2E4. \uC77D\uAE30 \uC804\uC6A9.",
2895
+ inputSchema: {},
2896
+ annotations: { readOnlyHint: true }
2897
+ }, async () => runTool("get_memos", () => getMemos(ctx)));
2898
+ server.registerTool("get_bookmarks", {
2899
+ title: "\uBD81\uB9C8\uD06C(\uB9C1\uD06C \uBAA8\uC74C)",
2900
+ description: "\uBD81\uB9C8\uD06C\uB97C \uADF8\uB8F9\uBCC4\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4(\uC544\uCE74\uC774\uBE0C \uADF8\uB8F9 \uC81C\uC678). \uC774\uB984\xB7URL \uC740 \uBBFC\uAC10\uD560 \uC218 \uC788\uC5B4(URL \uC5D0 \uD1A0\uD070\xB7\uD0A4 \uD3EC\uD568 \uAC00\uB2A5) SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uC77C \uB54C\uB9CC \uD0C8\uC2DD\uBCC4 \uD6C4 \uB178\uCD9C\uB418\uACE0, \uAEBC\uC838 \uC788\uC73C\uBA74 \uAC1C\uC218\uB9CC \uBC18\uD658\uD569\uB2C8\uB2E4. \uC77D\uAE30 \uC804\uC6A9.",
2901
+ inputSchema: {},
2902
+ annotations: { readOnlyHint: true }
2903
+ }, async () => runTool("get_bookmarks", () => getBookmarks(ctx)));
2904
+ server.registerTool("add_observation", {
2905
+ title: "\uAD00\uCC30\uAE30\uB85D \uC785\uB825",
2906
+ description: "\uD559\uC0DD \uD1A0\uD070\uC5D0 \uAD00\uCC30\uAE30\uB85D\uC744 \uCD94\uAC00(append)\uD569\uB2C8\uB2E4. \uC678\uBD80 \uC778\uC790\uB294 \uD1A0\uD070\uB9CC \uBC1B\uC2B5\uB2C8\uB2E4(\uC774\uB984 \uAE08\uC9C0). \uC4F0\uAE30\uB294 SSAMPIN_BRIDGE_ALLOW_WRITE=1 \uC77C \uB54C\uB9CC \uD65C\uC131\uC774\uBA70 \uC324\uD540\uC744 \uB2EB\uC740 \uC0C1\uD0DC\uB97C \uAD8C\uC7A5\uD569\uB2C8\uB2E4.",
2907
+ inputSchema: {
2908
+ studentToken: z.string().describe("list_students \uAC00 \uBC18\uD658\uD55C \uD559\uC0DD \uD1A0\uD070"),
2909
+ content: z.string().min(1).max(500).describe("\uAD00\uCC30 \uB0B4\uC6A9(\uCD5C\uB300 500\uC790)"),
2910
+ tags: z.array(z.string()).optional(),
2911
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
2912
+ idempotencyKey: z.string().optional().describe("\uC7AC\uC2DC\uB3C4 \uC911\uBCF5 \uBC29\uC9C0 \uD0A4")
2913
+ },
2914
+ annotations: { readOnlyHint: false }
2915
+ }, async (args) => runTool("add_observation", () => addObservation(ctx, args)));
2916
+ server.registerTool("get_observations", {
2917
+ title: "\uAD00\uCC30\uAE30\uB85D \uC870\uD68C(\uC0DD\uAE30\uBD80 \uADFC\uAC70)",
2918
+ description: "\uD559\uC0DD \uD1A0\uD070\uC758 \uAD00\uCC30\uAE30\uB85D\uC744 \uD0C8\uC2DD\uBCC4\uD574 \uBC18\uD658\uD569\uB2C8\uB2E4(\uC0DD\uAE30\uBD80 \uCD08\uC548 \uADFC\uAC70 \uC790\uB8CC). \uAC01 \uAE30\uB85D\uC758 observationId \uB97C \uCD08\uC548 \uBB38\uC7A5 \uADFC\uAC70\uB85C \uC778\uC6A9\uD558\uC138\uC694. \uB0B4\uC6A9 \uB178\uCD9C\uC740 \uAD50\uC0AC\uAC00 \uBD80\uC5EC\uD55C \uBC94\uC704 \uB3D9\uC758(\uD559\uC0DD\xB7\uAE30\uAC04\xB7\uBAA9\uC801\xB7\uB9CC\uB8CC) \uB610\uB294 SSAMPIN_BRIDGE_ALLOW_CONTENT=1 \uB9C8\uC2A4\uD130 \uC2A4\uC704\uCE58\uAC00 \uC788\uC5B4\uC57C \uD65C\uC131\uB429\uB2C8\uB2E4. \uB3D9\uC758\uAC00 \uC5C6\uC73C\uBA74 \uAD50\uC0AC\uC5D0\uAC8C CLI \uB3D9\uC758 \uBD80\uC5EC\uB97C \uC694\uCCAD\uD558\uC138\uC694(AI \uB294 \uB3D9\uC758\uB97C \uBC1C\uAE09\uD560 \uC218 \uC5C6\uC74C). \uC77D\uAE30 \uC804\uC6A9.",
2919
+ inputSchema: {
2920
+ studentToken: z.string().describe("list_students \uAC00 \uBC18\uD658\uD55C \uD559\uC0DD \uD1A0\uD070"),
2921
+ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("\uC2DC\uC791\uC77C(YYYY-MM-DD)"),
2922
+ to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("\uC885\uB8CC\uC77C(YYYY-MM-DD)")
2923
+ },
2924
+ annotations: { readOnlyHint: true }
2925
+ }, async (args) => runTool("get_observations", () => getObservations(ctx, args)));
2926
+ server.registerTool("check_record_draft", {
2927
+ title: "\uC0DD\uAE30\uBD80 \uCD08\uC548 \uADFC\uAC70 \uAC80\uC99D(\uC5B4\uD718)",
2928
+ description: '\uC0DD\uAE30\uBD80 \uCD08\uC548\uC758 \uAC01 \uBB38\uC7A5\uC774 \uAD00\uCC30\uAE30\uB85D\uC5D0 \uC5B4\uD718\uC801\uC73C\uB85C \uADFC\uAC70\uD558\uB294\uC9C0 \uAC80\uC0AC\uD569\uB2C8\uB2E4. \uC5C6\uB294 \uD1A0\uD070 \uC778\uC6A9\xB7\uB2E4\uBB38\uC7A5\xB7\uB0B4\uC6A9 \uBD88\uC77C\uCE58 \uBB38\uC7A5\uC744 flag(supported=false)\uD569\uB2C8\uB2E4. \uC774\uAC83\uC740 "\uC2B9\uC778"\uC774 \uC544\uB2C8\uB77C \uC5B4\uD718 \uADFC\uAC70 \uAC80\uC0AC\uC774\uBA70, \uC758\uBBF8\xB7\uAE08\uC9C0\uD45C\uD604\xB7\uAE30\uC7AC\uC694\uB839 \uC801\uD569\uC131\uC740 \uD310\uB2E8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC0DD\uAE30\uBD80\uB294 \uBC95\uC815 \uAE30\uB85D\uC774\uBBC0\uB85C flag \uBB38\uC7A5\uC740 \uC218\uC815\xB7\uC0AD\uC81C\uD558\uACE0 \uAD50\uC0AC\uAC00 \uBC18\uB4DC\uC2DC \uCD5C\uC885 \uAC80\uD1A0\uD558\uC138\uC694. claims \uC758 observationIds \uB294 get_observations \uAC00 \uC900 \uAD00\uCC30 \uD1A0\uD070\uC744 \uC4F0\uACE0, from/to \uB85C \uC791\uC131 \uBC94\uC704\uB97C \uB9DE\uCD94\uC138\uC694. \uC77D\uAE30 \uC804\uC6A9.',
2929
+ inputSchema: {
2930
+ studentToken: z.string(),
2931
+ claims: z.array(z.object({ text: z.string(), observationIds: z.array(z.string()) })).describe("\uCD08\uC548 \uBB38\uC7A5(\uB2E8\uC77C \uBB38\uC7A5)\uACFC \uAC01 \uBB38\uC7A5\uC758 \uADFC\uAC70 \uAD00\uCC30 \uD1A0\uD070 \uBAA9\uB85D"),
2932
+ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("\uAC80\uC99D \uB300\uC0C1 \uC2DC\uC791\uC77C"),
2933
+ to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("\uAC80\uC99D \uB300\uC0C1 \uC885\uB8CC\uC77C"),
2934
+ level: z.enum(["elementary", "middle", "high", "\uCD08\uB4F1\uD559\uAD50", "\uC911\uD559\uAD50", "\uACE0\uB4F1\uD559\uAD50"]).optional().describe("\uD559\uAD50\uAE09 \u2014 \uD559\uAD50\uAE09\uBCC4 \uACE0\uC704\uD5D8 \uC5B4\uD718 \uC801\uC6A9(\uBBF8\uC9C0\uC815 \uC2DC \uACF5\uD1B5)"),
2935
+ year: z.string().regex(/^\d{4}$/).optional().describe("\uAE30\uC7AC\uC694\uB839 \uC5F0\uB3C4(\uC608: 2026)")
2936
+ },
2937
+ annotations: { readOnlyHint: true }
2938
+ }, async (args) => runTool("check_record_draft", () => checkRecordDraft(ctx, args)));
2939
+ server.registerTool("get_performance_feedback", {
2940
+ title: "\uC218\uD589\uD3C9\uAC00 \uC9C8\uC801 \uD53C\uB4DC\uBC31(\uC810\uC218 \uC81C\uC678)",
2941
+ description: '\uC218\uC5C5\uBC18 \uD559\uC0DD\uC758 \uC218\uD589\uD3C9\uAC00(\uB8E8\uBE0C\uB9AD) \uCC44\uC810\uC744 "\uB3C4\uB2EC \uC218\uC900 \uC774\uB984\xB7\uC131\uCDE8 \uC124\uBA85\xB7\uC694\uC18C \uBA54\uBAA8\xB7\uCD1D\uD3C9"\uC73C\uB85C \uBC18\uD658\uD569\uB2C8\uB2E4. \uC810\uC218\xB7\uBC30\uC810\xB7\uD569\uACC4\uB294 \uD3EC\uD568\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4(\uC0DD\uAE30\uBD80 \uC785\uB825 \uAE08\uC9C0 \uD56D\uBAA9). \uC138\uD2B9\uC758 \uC11C\uC220\uD615 \uC5ED\uB7C9\xB7\uD0DC\uB3C4 \uADFC\uAC70\uB85C\uB9CC \uD65C\uC6A9\uD558\uC138\uC694. list_students(classToken) \uAC00 \uC900 \uD559\uC0DD token \uC744 \uC4F0\uBA70, \uB3D9\uAE09\uC0DD \uC2E4\uBA85\uC740 \uD0C8\uC2DD\uBCC4\uB429\uB2C8\uB2E4. \uB0B4\uC6A9 \uB178\uCD9C \uB3D9\uC758(\uB610\uB294 \uB9C8\uC2A4\uD130 \uC2A4\uC704\uCE58) \uD544\uC694. \uC77D\uAE30 \uC804\uC6A9.',
2942
+ inputSchema: {
2943
+ studentToken: z.string().describe("list_students(classToken) \uAC00 \uBC18\uD658\uD55C \uC218\uC5C5\uBC18 \uD559\uC0DD \uD1A0\uD070")
2944
+ },
2945
+ annotations: { readOnlyHint: true }
2946
+ }, async (args) => runTool("get_performance_feedback", () => getPerformanceFeedback(ctx, args)));
2947
+ server.registerTool("get_grade_summary", {
2948
+ title: "\uC131\uC801 \uC9C8\uC801 \uC694\uC57D(\uC810\uC218\xB7\uC11D\uCC28 \uC81C\uC678)",
2949
+ description: '\uC218\uC5C5\uBC18 \uD559\uC0DD\uC758 \uC131\uC801\uC744 "\uC131\uCDE8\uB3C4(A~E)\xB7\uD3C9\uAC00\uC601\uC5ED\xB7\uD3C9\uAC00\uBC29\uBC95\xB7\uC751\uC2DC\uC5EC\uBD80\xB7\uC218\uD589 \uC99D\uBE59 \uBA54\uBAA8"\uB85C \uC694\uC57D\uD574 \uBC18\uD658\uD569\uB2C8\uB2E4. \uC6D0\uC810\uC218\xB7\uD658\uC0B0\uC810\xB7\uC11D\uCC28\uB294 \uD3EC\uD568\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4(\uC0DD\uAE30\uBD80 \uC785\uB825 \uAE08\uC9C0 \uD56D\uBAA9). \uC11C\uC220\uD615 \uADFC\uAC70\uB85C\uB9CC \uD65C\uC6A9\uD558\uACE0 \uC810\uC218/\uC11D\uCC28\uB294 \uC0DD\uAE30\uBD80\uC5D0 \uC801\uC9C0 \uB9C8\uC138\uC694. list_students(classToken) \uC758 \uD559\uC0DD token \uC744 \uC4F0\uBA70 \uB0B4\uC6A9 \uB178\uCD9C \uB3D9\uC758 \uD544\uC694. \uC77D\uAE30 \uC804\uC6A9.',
2950
+ inputSchema: {
2951
+ studentToken: z.string().describe("list_students(classToken) \uAC00 \uBC18\uD658\uD55C \uC218\uC5C5\uBC18 \uD559\uC0DD \uD1A0\uD070")
2952
+ },
2953
+ annotations: { readOnlyHint: true }
2954
+ }, async (args) => runTool("get_grade_summary", () => getGradeSummaryTool(ctx, args)));
2955
+ server.registerTool("get_record_guidelines", {
2956
+ title: "\uC0DD\uAE30\uBD80 \uC791\uC131 \uCC38\uC870 \uC6D0\uCE59",
2957
+ description: "\uC0DD\uAE30\uBD80(\uD559\uAD50\uC0DD\uD65C\uAE30\uB85D\uBD80) \uC791\uC131 \uC2DC \uB530\uB77C\uC57C \uD560 \uD575\uC2EC \uC6D0\uCE59 \uC694\uC57D\uACFC \uADFC\uAC70 \uCD9C\uCC98\uB97C \uBC18\uD658\uD569\uB2C8\uB2E4. level(\uCD08/\uC911/\uACE0) \uC9C0\uC815 \uC2DC \uD559\uAD50\uAE09\uBCC4 \uCD94\uAC00 \uC6D0\uCE59\uC744 \uD3EC\uD568\uD558\uBA70, \uAC01 \uC6D0\uCE59\uC740 \uADFC\uAC70(\uD6C8\uB839\xB7\uAC1C\uC815\uC548\uB0B4\xB7\uD3EC\uD138)\uB97C \uB3D9\uBC18\uD569\uB2C8\uB2E4. \uC6D0\uBB38 \uD655\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4. \uC77D\uAE30 \uC804\uC6A9.",
2958
+ inputSchema: {
2959
+ level: z.enum(["elementary", "middle", "high", "\uCD08\uB4F1\uD559\uAD50", "\uC911\uD559\uAD50", "\uACE0\uB4F1\uD559\uAD50"]).optional().describe("\uD559\uAD50\uAE09(\uBBF8\uC9C0\uC815 \uC2DC \uACF5\uD1B5 \uC6D0\uCE59\uB9CC)"),
2960
+ year: z.string().regex(/^\d{4}$/).optional().describe("\uAE30\uC7AC\uC694\uB839 \uC5F0\uB3C4(\uC608: 2026)")
2961
+ },
2962
+ annotations: { readOnlyHint: true }
2963
+ }, async (args) => runTool("get_record_guidelines", () => getRecordGuidelines(args)));
2964
+ return server;
2965
+ }
2966
+
2967
+ // ../packages/mcp/dist/index.js
2968
+ async function main() {
2969
+ const server = createSsampinMcpServer();
2970
+ const transport = new StdioServerTransport();
2971
+ await server.connect(transport);
2972
+ process.stderr.write("[ssampin-mcp] connected (stdio)\n");
2973
+ }
2974
+ main().catch((err) => {
2975
+ process.stderr.write(`[ssampin-mcp] fatal: ${err instanceof Error ? err.message : String(err)}
2976
+ `);
2977
+ process.exit(1);
2978
+ });