careermate 0.0.8 → 0.0.9

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.
Files changed (50) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +78 -81
  4. package/bin/careermate.mjs +11 -2
  5. package/dist/career-os/eop/cover-letter.md +10 -4
  6. package/dist/career-os/eop/interview-prep.md +1 -0
  7. package/dist/career-os/knowledge/cover-letter.md +4 -0
  8. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  9. package/dist/doctor.mjs +888 -340
  10. package/dist/mcp.mjs +904 -350
  11. package/dist/migrate.mjs +208 -16
  12. package/dist/public/i18n/en/applications.js +19 -0
  13. package/dist/public/i18n/en/common.js +6 -0
  14. package/dist/public/i18n/en/jobs.js +17 -0
  15. package/dist/public/i18n/en/settings.js +5 -2
  16. package/dist/public/i18n/ko/applications.js +19 -0
  17. package/dist/public/i18n/ko/common.js +5 -0
  18. package/dist/public/i18n/ko/jobs.js +17 -0
  19. package/dist/public/i18n/ko/settings.js +5 -2
  20. package/dist/public/lib.js +29 -0
  21. package/dist/public/pages/applications.js +86 -10
  22. package/dist/public/pages/documents.js +16 -7
  23. package/dist/public/pages/home.js +12 -0
  24. package/dist/public/pages/interview.js +11 -4
  25. package/dist/public/pages/jobs.js +71 -1
  26. package/dist/public/pages/settings.js +2 -2
  27. package/dist/public/styles.css +13 -0
  28. package/dist/web.mjs +544 -199
  29. package/docs/FAQ.md +13 -3
  30. package/docs/MCP_TOOLS.md +22 -9
  31. package/docs/career-os/eop/cover-letter.md +10 -4
  32. package/docs/career-os/eop/interview-prep.md +1 -0
  33. package/docs/career-os/knowledge/cover-letter.md +4 -0
  34. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  35. package/package.json +3 -1
  36. package/packages/core/src/index.ts +1 -0
  37. package/packages/core/src/jobsearch.ts +6 -2
  38. package/packages/core/src/offers.ts +41 -0
  39. package/packages/core/src/services.ts +5 -1
  40. package/packages/core/src/summary.ts +245 -19
  41. package/packages/db/src/connection.ts +70 -2
  42. package/packages/db/src/index.ts +1 -0
  43. package/packages/db/src/repositories.ts +140 -8
  44. package/packages/db/src/schema.ts +140 -8
  45. package/packages/mcp-tools/src/result.ts +9 -0
  46. package/packages/mcp-tools/src/tools.ts +143 -20
  47. package/packages/parsers/src/documents.ts +7 -1
  48. package/packages/prompts/src/humanize.ts +4 -0
  49. package/packages/shared/src/schemas.ts +134 -0
  50. package/scripts/init.ts +2 -0
@@ -6,6 +6,8 @@ import {
6
6
  type ApplicationStatus,
7
7
  APPLICATION_STATUS_LABELS,
8
8
  INTERVIEW_UNLOCK_STATUSES,
9
+ REJECTED_STAGES,
10
+ type RejectedStage,
9
11
  type JobRecord,
10
12
  type ApplicationRecord,
11
13
  type CoverLetterRecord,
@@ -22,6 +24,7 @@ import {
22
24
  experienceRepo,
23
25
  projectRepo,
24
26
  timelineRepo,
27
+ documentRepo,
25
28
  } from '@careermate/db';
26
29
  import { getOnboardingStatus } from './onboarding.ts';
27
30
 
@@ -46,6 +49,8 @@ export interface HomeSummary {
46
49
  interview_todo: { job: JobRecord; status_label: string }[];
47
50
  deadlines: { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number }[];
48
51
  followups: { job: JobRecord; days_since: number }[];
52
+ /** Upcoming hiring-schedule stages (면접·인적성·발표) within SCHEDULE_SOON_DAYS, date/time asc. */
53
+ upcoming_schedule: { job: JobRecord; label: string; date: string; time?: string; d_day: number }[];
49
54
  recent_activity: ActivityRecord[];
50
55
  }
51
56
 
@@ -55,10 +60,36 @@ const ACTIVE: ApplicationStatus[] = ['draft', 'planned', 'applied', 'document_pa
55
60
  const DEADLINE_ACTIONABLE: ApplicationStatus[] = ['draft', 'planned'];
56
61
  // Surface a deadline once it's within this many days (overdue items are always shown).
57
62
  const DEADLINE_SOON_DAYS = 14;
58
- // Suggest a follow-up when an application has sat in 'applied' this many days with no response.
59
- const FOLLOWUP_STALE_DAYS = 10;
63
+ // Surface a hiring-schedule stage (면접·인적성·발표) once it's within this many days (today = 0).
64
+ const SCHEDULE_SOON_DAYS = 7;
60
65
  const DAY_MS = 86400000;
61
66
 
67
+ /**
68
+ * Collect every future (today or later) schedule stage across all jobs, newest date last,
69
+ * so both the home lane and the AG digest derive their views from one deterministic list.
70
+ * Each item carries its D-day (0 = today). Sort: date asc, then time asc (timeless entries last).
71
+ */
72
+ interface FutureScheduleItem {
73
+ job: JobRecord;
74
+ label: string;
75
+ date: string;
76
+ time?: string;
77
+ d_day: number;
78
+ }
79
+ function collectFutureSchedule(jobs: JobRecord[], now: Date): FutureScheduleItem[] {
80
+ const items: FutureScheduleItem[] = [];
81
+ for (const job of jobs) {
82
+ for (const s of job.schedule ?? []) {
83
+ const d_day = daysUntilDate(s.date, now);
84
+ if (d_day == null || d_day < 0) continue; // past stages drop off
85
+ items.push({ job, label: s.label, date: s.date, time: s.time, d_day });
86
+ }
87
+ }
88
+ return items.sort((a, b) =>
89
+ a.date < b.date ? -1 : a.date > b.date ? 1 : (a.time || '99:99').localeCompare(b.time || '99:99'),
90
+ );
91
+ }
92
+
62
93
  /** Whole days from `now`'s local midnight to a `YYYY-MM-DD` date (future > 0, today = 0, past < 0); null if unparseable. */
63
94
  function daysUntilDate(dateStr: string | null | undefined, now: Date): number | null {
64
95
  if (!dateStr) return null;
@@ -77,6 +108,31 @@ function daysSinceTimestamp(iso: string | null | undefined, now: Date): number |
77
108
  return Math.floor((now.getTime() - d.getTime()) / DAY_MS);
78
109
  }
79
110
 
111
+ /**
112
+ * Business days (Mon–Fri) elapsed strictly after an ISO timestamp's local date up
113
+ * to `now`'s local date, inclusive of the end date. Weekends excluded; public
114
+ * holidays are ignored (deterministic, no calendar dependency). Null if unparseable.
115
+ * e.g. applied Friday → next Friday counts 5 business days (Mon…Fri).
116
+ */
117
+ export function businessDaysSince(iso: string | null | undefined, now: Date): number | null {
118
+ if (!iso) return null;
119
+ const start = new Date(iso);
120
+ if (isNaN(start.getTime())) return null;
121
+ const s = new Date(start);
122
+ s.setHours(0, 0, 0, 0);
123
+ const e = new Date(now);
124
+ e.setHours(0, 0, 0, 0);
125
+ if (e.getTime() <= s.getTime()) return 0;
126
+ let count = 0;
127
+ const cursor = new Date(s);
128
+ while (cursor.getTime() < e.getTime()) {
129
+ cursor.setDate(cursor.getDate() + 1);
130
+ const dow = cursor.getDay();
131
+ if (dow !== 0 && dow !== 6) count++;
132
+ }
133
+ return count;
134
+ }
135
+
80
136
  export function jobWithMeta(job: JobRecord): JobWithMeta {
81
137
  const app = applicationRepo.getByJob(job.id);
82
138
  const fit = fitRepo.getByJob(job.id);
@@ -90,6 +146,9 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
90
146
  const applications = applicationRepo.list();
91
147
  const coverLetters = coverLetterRepo.list();
92
148
  const statusByJob = new Map(applications.map((a) => [a.job_id, a]));
149
+ // Index once instead of a per-application jobRepo.get / interviewRepo.getByJob (N+1).
150
+ const jobById = new Map(jobs.map((j) => [j.id, j]));
151
+ const preppedJobIds = new Set(interviewRepo.list().map((p) => p.job_id));
93
152
 
94
153
  const status_breakdown = (Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[]).map((status) => ({
95
154
  status,
@@ -101,9 +160,9 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
101
160
 
102
161
  // Interview to-do: passed-to-interview stages without saved prep yet.
103
162
  const interview_todo = applications
104
- .filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !interviewRepo.getByJob(a.job_id))
163
+ .filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !preppedJobIds.has(a.job_id))
105
164
  .map((a) => {
106
- const job = jobRepo.get(a.job_id);
165
+ const job = jobById.get(a.job_id);
107
166
  return job ? { job, status_label: APPLICATION_STATUS_LABELS[a.status] } : null;
108
167
  })
109
168
  .filter((x): x is { job: JobRecord; status_label: string } => x !== null);
@@ -122,19 +181,30 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
122
181
  .sort((a, b) => a.days_left - b.days_left)
123
182
  .slice(0, 5);
124
183
 
125
- // Follow-up nudges: applications sitting in 'applied' with no response for a while.
184
+ // Follow-up nudges: applications sitting in 'applied' with no response. Gate on
185
+ // BUSINESS days with the same predicate as getActionDigest (FOLLOWUP_DUE_BIZ_DAYS)
186
+ // so Home and the AI digest surface a follow-up at the same moment; the displayed
187
+ // days_since stays in calendar days.
126
188
  const followups = applications
127
189
  .filter((a) => a.status === 'applied')
128
190
  .map((a) => {
129
- const days_since = daysSinceTimestamp(a.applied_at, now);
130
- if (days_since == null || days_since < FOLLOWUP_STALE_DAYS) return null;
131
- const job = jobRepo.get(a.job_id);
191
+ const biz = businessDaysSince(a.applied_at, now);
192
+ if (biz == null || biz < FOLLOWUP_DUE_BIZ_DAYS) return null;
193
+ const days_since = daysSinceTimestamp(a.applied_at, now) ?? 0;
194
+ const job = jobById.get(a.job_id);
132
195
  return job ? { job, days_since } : null;
133
196
  })
134
197
  .filter((x): x is { job: JobRecord; days_since: number } => x !== null)
135
198
  .sort((a, b) => b.days_since - a.days_since)
136
199
  .slice(0, 5);
137
200
 
201
+ // Upcoming hiring-schedule stages within a week — same "what's next" lane as
202
+ // interview prep / deadlines. Past stages already dropped in collectFutureSchedule.
203
+ const upcoming_schedule = collectFutureSchedule(jobs, now)
204
+ .filter((x) => x.d_day <= SCHEDULE_SOON_DAYS)
205
+ .map((x) => ({ job: x.job, label: x.label, date: x.date, ...(x.time ? { time: x.time } : {}), d_day: x.d_day }))
206
+ .slice(0, 6);
207
+
138
208
  return {
139
209
  onboarding,
140
210
  counts: {
@@ -146,7 +216,7 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
146
216
  status_breakdown,
147
217
  recent_jobs: jobs.slice(0, 6).map(jobWithMeta),
148
218
  in_progress: active.slice(0, 6).map((application) => {
149
- const job = jobRepo.get(application.job_id);
219
+ const job = jobById.get(application.job_id) ?? null;
150
220
  return {
151
221
  application,
152
222
  job,
@@ -157,6 +227,7 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
157
227
  interview_todo,
158
228
  deadlines,
159
229
  followups,
230
+ upcoming_schedule,
160
231
  recent_activity: activityRepo.recent(10),
161
232
  };
162
233
  }
@@ -176,12 +247,44 @@ export function listRecentActivity(limit = 20): ActivityRecord[] {
176
247
  const POST_PASS_STALE_DAYS = 7; // document_passed → interview scheduling wait
177
248
  const POST_INTERVIEW_STALE_DAYS = 7; // interview → result wait
178
249
 
250
+ // Deterministic follow-up timing for applied-but-silent postings (community
251
+ // research U-timing: "when should I follow up?" is the #1 job-seeker anxiety).
252
+ // Counted in business days from the apply date — the 5–7 day window is the widely
253
+ // advised sweet spot; earlier reads as pushy, later means the moment has passed.
254
+ const FOLLOWUP_DUE_BIZ_DAYS = 5; // 5–7 business days: prime window to follow up (once, politely)
255
+ const FOLLOWUP_OVERDUE_BIZ_DAYS = 8; // ≥8 business days: follow-up window has passed
256
+ const FOLLOWUP_ONHOLD_CAL_DAYS = 45; // ≥45 calendar days silent: suggest moving to on_hold
257
+
179
258
  export interface FollowupItem {
180
259
  job: JobRecord;
181
260
  status: ApplicationStatus;
182
261
  status_label: string;
183
262
  days_since: number;
184
263
  reason: string;
264
+ /** Business days since applying — present only for 'applied' items in the follow-up window. */
265
+ business_days_since?: number;
266
+ /** 'due' at 5–7 business days, 'overdue' at ≥8 — advisory nudge, not a hard rule. */
267
+ followup_timing?: 'due' | 'overdue';
268
+ /** ≥45 calendar days with no response: hint the AI to offer moving this to on_hold. */
269
+ suggest_on_hold?: boolean;
270
+ }
271
+
272
+ /** One upcoming hiring-schedule stage the AI should flag (오늘~7일 내). */
273
+ export interface UpcomingScheduleItem {
274
+ job_id: string;
275
+ company: string;
276
+ position: string;
277
+ label: string;
278
+ date: string;
279
+ time?: string;
280
+ /** Whole days until the stage (0 = today). */
281
+ d_day: number;
282
+ }
283
+
284
+ /** Two or more DIFFERENT postings landing a stage on the same date — a scheduling collision. */
285
+ export interface ScheduleConflict {
286
+ date: string;
287
+ items: { job_id: string; company: string; position: string; label: string; time?: string }[];
185
288
  }
186
289
 
187
290
  export interface ActionDigest {
@@ -190,6 +293,10 @@ export interface ActionDigest {
190
293
  followups: FollowupItem[];
191
294
  deadlines: { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number }[];
192
295
  interview_todo: { job: JobRecord; status_label: string }[];
296
+ /** Upcoming hiring-schedule stages within a week (면접·인적성·발표), date/time asc. */
297
+ upcoming_schedule: UpcomingScheduleItem[];
298
+ /** Same-date collisions among all future stages — surface so the user can resolve them. */
299
+ schedule_conflicts: ScheduleConflict[];
193
300
  }
194
301
 
195
302
  /**
@@ -201,15 +308,35 @@ export function getActionDigest(now: Date = new Date()): ActionDigest {
201
308
  const applications = applicationRepo.list();
202
309
  const jobs = jobRepo.list();
203
310
  const statusByJob = new Map(applications.map((a) => [a.job_id, a]));
311
+ // Index once instead of a per-application jobRepo.get / interviewRepo.getByJob (N+1).
312
+ const jobById = new Map(jobs.map((j) => [j.id, j]));
313
+ const preppedJobIds = new Set(interviewRepo.list().map((p) => p.job_id));
204
314
 
205
315
  const followups: FollowupItem[] = [];
206
316
  for (const a of applications) {
207
- const job = jobRepo.get(a.job_id);
317
+ const job = jobById.get(a.job_id);
208
318
  if (!job) continue;
209
319
  if (a.status === 'applied') {
210
- const days = daysSinceTimestamp(a.applied_at, now);
211
- if (days != null && days >= FOLLOWUP_STALE_DAYS)
212
- followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `지원 후 ${days}일째 응답 없음 — 정중한 후속 문의를 고려할 시점` });
320
+ // Surface once the business-day follow-up window opens (≈5 business days),
321
+ // regardless of the softer calendar-day home-lane threshold.
322
+ const cal = daysSinceTimestamp(a.applied_at, now);
323
+ const biz = businessDaysSince(a.applied_at, now);
324
+ if (biz != null && biz >= FOLLOWUP_DUE_BIZ_DAYS) {
325
+ const overdue = biz >= FOLLOWUP_OVERDUE_BIZ_DAYS;
326
+ const item: FollowupItem = {
327
+ job,
328
+ status: a.status,
329
+ status_label: APPLICATION_STATUS_LABELS[a.status],
330
+ days_since: cal ?? 0,
331
+ business_days_since: biz,
332
+ followup_timing: overdue ? 'overdue' : 'due',
333
+ reason: overdue
334
+ ? `지원 후 ${biz}영업일 경과 — 후속 문의 적기가 지났습니다(무리한 독촉은 역효과이니 아직 안 했다면 정중히 1회만)`
335
+ : `지원 후 ${biz}영업일 — 팔로업 적기(문의는 정중히 1회)`,
336
+ };
337
+ if (cal != null && cal >= FOLLOWUP_ONHOLD_CAL_DAYS) item.suggest_on_hold = true;
338
+ followups.push(item);
339
+ }
213
340
  } else if (a.status === 'document_passed') {
214
341
  const days = daysSinceTimestamp(a.updated_at, now);
215
342
  if (days != null && days >= POST_PASS_STALE_DAYS)
@@ -234,15 +361,56 @@ export function getActionDigest(now: Date = new Date()): ActionDigest {
234
361
  .sort((a, b) => a.days_left - b.days_left);
235
362
 
236
363
  const interview_todo = applications
237
- .filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !interviewRepo.getByJob(a.job_id))
364
+ .filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !preppedJobIds.has(a.job_id))
238
365
  .map((a) => {
239
- const job = jobRepo.get(a.job_id);
366
+ const job = jobById.get(a.job_id);
240
367
  return job ? { job, status_label: APPLICATION_STATUS_LABELS[a.status] } : null;
241
368
  })
242
369
  .filter((x): x is { job: JobRecord; status_label: string } => x !== null);
243
370
 
371
+ // 전형 일정: 오늘~7일 내 다가오는 일정(D-day) + 같은 날짜 충돌(공고 무관). 충돌은 7일에 얽매이지
372
+ // 않고 미래 전체에서 감지한다(다음 달 두 면접이 같은 날이면 미리 알려야 하므로).
373
+ const futureSchedule = collectFutureSchedule(jobs, now);
374
+ const upcoming_schedule: UpcomingScheduleItem[] = futureSchedule
375
+ .filter((x) => x.d_day <= SCHEDULE_SOON_DAYS)
376
+ .map((x) => ({
377
+ job_id: x.job.id,
378
+ company: x.job.company,
379
+ position: x.job.position,
380
+ label: x.label,
381
+ ...(x.time ? { time: x.time } : {}),
382
+ date: x.date,
383
+ d_day: x.d_day,
384
+ }));
385
+
386
+ const byDate = new Map<string, FutureScheduleItem[]>();
387
+ for (const x of futureSchedule) {
388
+ const arr = byDate.get(x.date) ?? [];
389
+ arr.push(x);
390
+ byDate.set(x.date, arr);
391
+ }
392
+ const schedule_conflicts: ScheduleConflict[] = [];
393
+ for (const [date, items] of byDate) {
394
+ // A conflict means two DIFFERENT postings collide on one date. Multiple stages
395
+ // of the SAME posting on a day (e.g. back-to-back rounds) is a normal itinerary,
396
+ // not a clash — gate on distinct job_id ≥ 2.
397
+ const distinctJobs = new Set(items.map((x) => x.job.id));
398
+ if (distinctJobs.size < 2) continue;
399
+ schedule_conflicts.push({
400
+ date,
401
+ items: items.map((x) => ({
402
+ job_id: x.job.id,
403
+ company: x.job.company,
404
+ position: x.job.position,
405
+ label: x.label,
406
+ ...(x.time ? { time: x.time } : {}),
407
+ })),
408
+ });
409
+ }
410
+ schedule_conflicts.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
411
+
244
412
  const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
245
- return { today, followups, deadlines, interview_todo };
413
+ return { today, followups, deadlines, interview_todo, upcoming_schedule, schedule_conflicts };
246
414
  }
247
415
 
248
416
  /* ---------------------------------------------------------- job pre-screen */
@@ -256,6 +424,18 @@ export function getActionDigest(now: Date = new Date()): ActionDigest {
256
424
  const normToken = (s: string): string =>
257
425
  s.toLowerCase().trim().replace(/^[.,/()\s]+|[.,/()\s]+$/g, '');
258
426
 
427
+ // Résumé-body keyword match. A pure-ASCII tech token (java, ai, c#, node.js) must
428
+ // match only as a whole token — bounded by non-[a-z0-9] — so 'ai' does NOT hit
429
+ // 'email' nor 'java' 'javascript'. Non-ASCII tokens (Korean 결제) keep a substring
430
+ // test so '결제' still matches '결제시스템' (Korean has no ASCII word boundaries).
431
+ // Both `token` and `text` arrive lowercased; caller guards length ≥ 2.
432
+ const ASCII_TOKEN_RE = /^[a-z0-9+#.]+$/;
433
+ const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
434
+ function resumeBodyHasToken(text: string, token: string): boolean {
435
+ if (!ASCII_TOKEN_RE.test(token)) return text.includes(token);
436
+ return new RegExp(`(?<![a-z0-9])${escapeRegExp(token)}(?![a-z0-9])`, 'i').test(text);
437
+ }
438
+
259
439
  export interface JobPrescreen {
260
440
  job_id: string;
261
441
  job_keywords: string[];
@@ -264,6 +444,14 @@ export interface JobPrescreen {
264
444
  /** matched / total keywords (0..1); null when the job has no keywords to screen. */
265
445
  coverage: number | null;
266
446
  user_skill_count: number;
447
+ /** Keywords found (case-insensitive) in the primary résumé body text. */
448
+ resume_matched: string[];
449
+ /** Union of structured-skill matches and résumé-body matches. */
450
+ combined_matched: string[];
451
+ /** combined_matched / total keywords (0..1); null when the job has no keywords. */
452
+ combined_coverage: number | null;
453
+ /** False when no résumé exists to check the body against (combined == skills only). */
454
+ resume_checked: boolean;
267
455
  }
268
456
 
269
457
  export function prescreenJob(jobId: string): JobPrescreen | null {
@@ -275,19 +463,42 @@ export function prescreenJob(jobId: string): JobPrescreen | null {
275
463
  for (const p of projectRepo.list()) for (const t of p.tech ?? []) userTokens.add(normToken(t));
276
464
  userTokens.delete('');
277
465
 
466
+ // Primary résumé (is_primary first, else most recent) body text for keyword matching.
467
+ const resume = documentRepo.primary('resume');
468
+ const resume_checked = resume != null;
469
+ const resumeText = (resume?.content ?? '').toLowerCase();
470
+
278
471
  const keywords = (job.keywords ?? []).filter((k) => k && k.trim());
279
472
  const matched: string[] = [];
280
473
  const missing: string[] = [];
474
+ const resume_matched: string[] = [];
281
475
  for (const kw of keywords) {
282
476
  const n = normToken(kw);
283
- const hit =
477
+ const skillHit =
284
478
  userTokens.has(n) ||
285
479
  // substring match only for tokens ≥3 chars (avoid "c"/"r" matching everything).
286
480
  (n.length >= 3 && [...userTokens].some((u) => u.length >= 3 && (u.includes(n) || n.includes(u))));
287
- (hit ? matched : missing).push(kw);
481
+ (skillHit ? matched : missing).push(kw);
482
+ // Résumé-body match: keyword appears as a whole token in the résumé text (see
483
+ // resumeBodyHasToken). Guard tokens ≥2 chars to avoid trivial short matches.
484
+ if (resume_checked && n.length >= 2 && resumeBodyHasToken(resumeText, n)) resume_matched.push(kw);
288
485
  }
289
486
  const coverage = keywords.length ? matched.length / keywords.length : null;
290
- return { job_id: jobId, job_keywords: keywords, matched, missing, coverage, user_skill_count: userTokens.size };
487
+ const combinedSet = new Set([...matched, ...resume_matched]);
488
+ const combined_matched = keywords.filter((k) => combinedSet.has(k));
489
+ const combined_coverage = keywords.length ? combined_matched.length / keywords.length : null;
490
+ return {
491
+ job_id: jobId,
492
+ job_keywords: keywords,
493
+ matched,
494
+ missing,
495
+ coverage,
496
+ user_skill_count: userTokens.size,
497
+ resume_matched,
498
+ combined_matched,
499
+ combined_coverage,
500
+ resume_checked,
501
+ };
291
502
  }
292
503
 
293
504
  /* ----------------------------------------------------------- pipeline stats */
@@ -308,6 +519,10 @@ export interface PipelineStats {
308
519
  /** Pass-through rates relative to "ever applied" (percent, 1 d.p.); null when denominator is 0. */
309
520
  rates: { document_pass: number | null; interview: number | null; offer: number | null };
310
521
  outcomes: { final_passed: number; rejected: number; in_progress: number; on_hold: number };
522
+ /** 탈락 회고: 현재 rejected인 지원 중 단계가 기록된 것의 단계별 건수(0인 단계는 생략). */
523
+ rejected_by_stage: { stage: RejectedStage; count: number }[];
524
+ /** rejected인데 단계 미기록 수 — 기록하면 어디서 떨어지는지 패턴이 드러난다. */
525
+ rejected_unrecorded: number;
311
526
  note: string;
312
527
  }
313
528
 
@@ -340,6 +555,15 @@ export function getPipelineStats(): PipelineStats {
340
555
  const pct = (n: number, d: number): number | null => (d > 0 ? Math.round((n / d) * 1000) / 10 : null);
341
556
  const cur = (s: ApplicationStatus) => applications.filter((a) => a.status === s).length;
342
557
 
558
+ // 탈락 회고: 현재 rejected인 지원만 대상으로 단계 분포를 센다. 단계 미기록(rejected_stage=null)은
559
+ // 별도 카운트해, "미기록이 많으면 기록을 유도" 하는 신호로 쓴다.
560
+ const rejectedApps = applications.filter((a) => a.status === 'rejected');
561
+ const rejected_by_stage = REJECTED_STAGES.map((stage) => ({
562
+ stage,
563
+ count: rejectedApps.filter((a) => a.rejected_stage === stage).length,
564
+ })).filter((s) => s.count > 0);
565
+ const rejected_unrecorded = rejectedApps.filter((a) => !a.rejected_stage).length;
566
+
343
567
  return {
344
568
  total: applications.length,
345
569
  by_status,
@@ -351,6 +575,8 @@ export function getPipelineStats(): PipelineStats {
351
575
  in_progress: cur('applied') + cur('document_passed') + cur('interview'),
352
576
  on_hold: cur('on_hold'),
353
577
  },
578
+ rejected_by_stage,
579
+ rejected_unrecorded,
354
580
  note:
355
581
  '단계별 도달 깔때기는 상태 변경 이력(타임라인)으로 복원한 실측치입니다(현재 rejected여도 거쳐간 최고 단계까지 집계). ' +
356
582
  '비율은 설명용이며 표본이 작으면 예측력이 낮습니다 — 추세 참고로만 쓰세요.',
@@ -4,18 +4,86 @@
4
4
  * Both the web server and the MCP server import this. SQLite with WAL handles
5
5
  * the two-process concurrency (dashboard editing + AI writing) cleanly.
6
6
  */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
7
9
  import { DatabaseSync } from 'node:sqlite';
8
- import { getDbPath } from './paths.ts';
9
- import { migrate } from './schema.ts';
10
+ import { getBackupsDir, getDbPath } from './paths.ts';
11
+ import { migrate, MIGRATIONS } from './schema.ts';
10
12
 
11
13
  let _db: DatabaseSync | null = null;
12
14
 
15
+ /** How many automatic pre-migration snapshots to keep (newest first). */
16
+ const PRE_MIGRATE_KEEP = 5;
17
+
18
+ /**
19
+ * `VACUUM INTO` a destination path, with the guard both backup callers need. The
20
+ * data dir can be set via env, so `destPath` isn't fully trusted: reject NUL/newline
21
+ * (can't be embedded safely in the SQL string literal) and single-quote-escape the
22
+ * rest. Throws on an unsafe path so the caller can fall back to fs.copyFileSync.
23
+ */
24
+ export function vacuumInto(db: DatabaseSync, destPath: string): void {
25
+ if (/[\0\n\r]/.test(destPath)) throw new Error('backup path not safe for SQL literal');
26
+ db.exec(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`);
27
+ }
28
+
29
+ /**
30
+ * Snapshot the DB into backups/ right before a schema upgrade runs. The DB is
31
+ * the user's only copy, so even though each migration is transactional, a bad
32
+ * migration shipped in an update must never be able to destroy data without a
33
+ * restore point. Best-effort: a failed backup logs to stderr but does not block
34
+ * boot (blocking would take both the dashboard and the MCP server down).
35
+ */
36
+ function preMigrationBackup(db: DatabaseSync): void {
37
+ try {
38
+ const hasMeta = db
39
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='_meta'`)
40
+ .get();
41
+ if (!hasMeta) return; // fresh file — nothing to protect yet
42
+ const row = db.prepare(`SELECT value FROM _meta WHERE key = 'schema_version'`).get() as
43
+ | { value: string }
44
+ | undefined;
45
+ const from = row ? Number(row.value) : 0;
46
+ if (from === 0 || from >= MIGRATIONS.length) return; // fresh, current, or newer — no upgrade will run
47
+
48
+ const dir = getBackupsDir();
49
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
50
+ const dest = path.join(dir, `pre-migrate-v${from}-${stamp}.sqlite`);
51
+ try {
52
+ vacuumInto(db, dest);
53
+ } catch {
54
+ fs.copyFileSync(getDbPath(), dest); // WAL is checkpointed on close; copy is a coarse fallback
55
+ }
56
+
57
+ // Prune old snapshots (ISO stamp in the name → lexicographic sort == time sort).
58
+ // fs.unlinkSync only — rmSync hard-crashes on Node 25/Windows.
59
+ const stale = fs
60
+ .readdirSync(dir)
61
+ .filter((f) => f.startsWith('pre-migrate-') && f.endsWith('.sqlite'))
62
+ .sort()
63
+ .reverse()
64
+ .slice(PRE_MIGRATE_KEEP);
65
+ for (const f of stale) {
66
+ try {
67
+ fs.unlinkSync(path.join(dir, f));
68
+ } catch {
69
+ /* best-effort */
70
+ }
71
+ }
72
+ } catch (err) {
73
+ // stderr only — stdout is the MCP transport channel.
74
+ console.error(
75
+ `[careermate] 업그레이드 전 자동 백업 실패(마이그레이션은 계속 진행): ${err instanceof Error ? err.message : String(err)}`,
76
+ );
77
+ }
78
+ }
79
+
13
80
  export function getDb(): DatabaseSync {
14
81
  if (_db) return _db;
15
82
  const db = new DatabaseSync(getDbPath());
16
83
  db.exec('PRAGMA journal_mode = WAL;');
17
84
  db.exec('PRAGMA foreign_keys = ON;');
18
85
  db.exec('PRAGMA busy_timeout = 5000;');
86
+ preMigrationBackup(db);
19
87
  migrate(db);
20
88
  _db = db;
21
89
  return db;
@@ -19,6 +19,7 @@ export {
19
19
  fitRepo,
20
20
  applicationRepo,
21
21
  interviewRepo,
22
+ offerRepo,
22
23
  activityRepo,
23
24
  timelineRepo,
24
25
  getEntityCounts,