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.
- package/AGENTS.md +2 -2
- package/LICENSE +1 -1
- package/README.md +78 -81
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +10 -4
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +4 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/doctor.mjs +888 -340
- package/dist/mcp.mjs +904 -350
- package/dist/migrate.mjs +208 -16
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +6 -0
- package/dist/public/i18n/en/jobs.js +17 -0
- package/dist/public/i18n/en/settings.js +5 -2
- package/dist/public/i18n/ko/applications.js +19 -0
- package/dist/public/i18n/ko/common.js +5 -0
- package/dist/public/i18n/ko/jobs.js +17 -0
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +29 -0
- package/dist/public/pages/applications.js +86 -10
- package/dist/public/pages/documents.js +16 -7
- package/dist/public/pages/home.js +12 -0
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +71 -1
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +13 -0
- package/dist/web.mjs +544 -199
- package/docs/FAQ.md +13 -3
- package/docs/MCP_TOOLS.md +22 -9
- package/docs/career-os/eop/cover-letter.md +10 -4
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +4 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/package.json +3 -1
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/jobsearch.ts +6 -2
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/services.ts +5 -1
- package/packages/core/src/summary.ts +245 -19
- package/packages/db/src/connection.ts +70 -2
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +140 -8
- package/packages/db/src/schema.ts +140 -8
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +143 -20
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/shared/src/schemas.ts +134 -0
- 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
|
-
//
|
|
59
|
-
const
|
|
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) && !
|
|
163
|
+
.filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !preppedJobIds.has(a.job_id))
|
|
105
164
|
.map((a) => {
|
|
106
|
-
const job =
|
|
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
|
|
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
|
|
130
|
-
if (
|
|
131
|
-
const
|
|
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 =
|
|
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 =
|
|
317
|
+
const job = jobById.get(a.job_id);
|
|
208
318
|
if (!job) continue;
|
|
209
319
|
if (a.status === 'applied') {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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) && !
|
|
364
|
+
.filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !preppedJobIds.has(a.job_id))
|
|
238
365
|
.map((a) => {
|
|
239
|
-
const job =
|
|
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
|
|
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
|
-
(
|
|
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
|
-
|
|
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;
|