careermate 0.0.7 → 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 +108 -91
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +13 -6
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +7 -3
- package/dist/career-os/knowledge/fit-matching.md +2 -0
- package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/dist/doctor.mjs +19760 -1018
- package/dist/mcp.mjs +19783 -1035
- package/dist/migrate.mjs +289 -20
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +10 -0
- package/dist/public/i18n/en/documents.js +0 -10
- package/dist/public/i18n/en/home.js +5 -0
- package/dist/public/i18n/en/jobs.js +18 -1
- package/dist/public/i18n/en/profile.js +0 -5
- 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 +9 -0
- package/dist/public/i18n/ko/documents.js +0 -10
- package/dist/public/i18n/ko/home.js +5 -0
- package/dist/public/i18n/ko/jobs.js +18 -1
- package/dist/public/i18n/ko/profile.js +0 -5
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +101 -0
- package/dist/public/pages/applications.js +95 -15
- package/dist/public/pages/documents.js +20 -14
- package/dist/public/pages/home.js +38 -6
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +71 -1
- package/dist/public/pages/profile.js +2 -3
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +52 -4
- package/dist/web.mjs +19001 -758
- package/docs/FAQ.md +13 -3
- package/docs/MCP_TOOLS.md +32 -12
- package/docs/career-os/eop/cover-letter.md +13 -6
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +7 -3
- package/docs/career-os/knowledge/fit-matching.md +2 -0
- package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/package.json +5 -2
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/jobsearch.ts +190 -0
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/services.ts +29 -3
- package/packages/core/src/summary.ts +431 -9
- package/packages/core/src/verify/charcount.ts +87 -0
- package/packages/core/src/verify/index.ts +1 -0
- package/packages/core/src/verify/lint.ts +5 -0
- package/packages/db/src/connection.ts +70 -2
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +179 -13
- package/packages/db/src/schema.ts +169 -8
- package/packages/exporters/src/docx.ts +165 -0
- package/packages/exporters/src/index.ts +142 -9
- package/packages/exporters/src/markdown.ts +16 -11
- package/packages/knowledge/src/index.ts +8 -3
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +303 -60
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/shared/src/schemas.ts +198 -0
- package/scripts/init.ts +7 -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,
|
|
@@ -18,6 +20,11 @@ import {
|
|
|
18
20
|
interviewRepo,
|
|
19
21
|
fitRepo,
|
|
20
22
|
activityRepo,
|
|
23
|
+
skillRepo,
|
|
24
|
+
experienceRepo,
|
|
25
|
+
projectRepo,
|
|
26
|
+
timelineRepo,
|
|
27
|
+
documentRepo,
|
|
21
28
|
} from '@careermate/db';
|
|
22
29
|
import { getOnboardingStatus } from './onboarding.ts';
|
|
23
30
|
|
|
@@ -42,6 +49,8 @@ export interface HomeSummary {
|
|
|
42
49
|
interview_todo: { job: JobRecord; status_label: string }[];
|
|
43
50
|
deadlines: { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number }[];
|
|
44
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 }[];
|
|
45
54
|
recent_activity: ActivityRecord[];
|
|
46
55
|
}
|
|
47
56
|
|
|
@@ -51,10 +60,36 @@ const ACTIVE: ApplicationStatus[] = ['draft', 'planned', 'applied', 'document_pa
|
|
|
51
60
|
const DEADLINE_ACTIONABLE: ApplicationStatus[] = ['draft', 'planned'];
|
|
52
61
|
// Surface a deadline once it's within this many days (overdue items are always shown).
|
|
53
62
|
const DEADLINE_SOON_DAYS = 14;
|
|
54
|
-
//
|
|
55
|
-
const
|
|
63
|
+
// Surface a hiring-schedule stage (면접·인적성·발표) once it's within this many days (today = 0).
|
|
64
|
+
const SCHEDULE_SOON_DAYS = 7;
|
|
56
65
|
const DAY_MS = 86400000;
|
|
57
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
|
+
|
|
58
93
|
/** Whole days from `now`'s local midnight to a `YYYY-MM-DD` date (future > 0, today = 0, past < 0); null if unparseable. */
|
|
59
94
|
function daysUntilDate(dateStr: string | null | undefined, now: Date): number | null {
|
|
60
95
|
if (!dateStr) return null;
|
|
@@ -73,6 +108,31 @@ function daysSinceTimestamp(iso: string | null | undefined, now: Date): number |
|
|
|
73
108
|
return Math.floor((now.getTime() - d.getTime()) / DAY_MS);
|
|
74
109
|
}
|
|
75
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
|
+
|
|
76
136
|
export function jobWithMeta(job: JobRecord): JobWithMeta {
|
|
77
137
|
const app = applicationRepo.getByJob(job.id);
|
|
78
138
|
const fit = fitRepo.getByJob(job.id);
|
|
@@ -86,6 +146,9 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
|
86
146
|
const applications = applicationRepo.list();
|
|
87
147
|
const coverLetters = coverLetterRepo.list();
|
|
88
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));
|
|
89
152
|
|
|
90
153
|
const status_breakdown = (Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[]).map((status) => ({
|
|
91
154
|
status,
|
|
@@ -97,9 +160,9 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
|
97
160
|
|
|
98
161
|
// Interview to-do: passed-to-interview stages without saved prep yet.
|
|
99
162
|
const interview_todo = applications
|
|
100
|
-
.filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !
|
|
163
|
+
.filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !preppedJobIds.has(a.job_id))
|
|
101
164
|
.map((a) => {
|
|
102
|
-
const job =
|
|
165
|
+
const job = jobById.get(a.job_id);
|
|
103
166
|
return job ? { job, status_label: APPLICATION_STATUS_LABELS[a.status] } : null;
|
|
104
167
|
})
|
|
105
168
|
.filter((x): x is { job: JobRecord; status_label: string } => x !== null);
|
|
@@ -118,19 +181,30 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
|
118
181
|
.sort((a, b) => a.days_left - b.days_left)
|
|
119
182
|
.slice(0, 5);
|
|
120
183
|
|
|
121
|
-
// 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.
|
|
122
188
|
const followups = applications
|
|
123
189
|
.filter((a) => a.status === 'applied')
|
|
124
190
|
.map((a) => {
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
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);
|
|
128
195
|
return job ? { job, days_since } : null;
|
|
129
196
|
})
|
|
130
197
|
.filter((x): x is { job: JobRecord; days_since: number } => x !== null)
|
|
131
198
|
.sort((a, b) => b.days_since - a.days_since)
|
|
132
199
|
.slice(0, 5);
|
|
133
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
|
+
|
|
134
208
|
return {
|
|
135
209
|
onboarding,
|
|
136
210
|
counts: {
|
|
@@ -142,7 +216,7 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
|
142
216
|
status_breakdown,
|
|
143
217
|
recent_jobs: jobs.slice(0, 6).map(jobWithMeta),
|
|
144
218
|
in_progress: active.slice(0, 6).map((application) => {
|
|
145
|
-
const job =
|
|
219
|
+
const job = jobById.get(application.job_id) ?? null;
|
|
146
220
|
return {
|
|
147
221
|
application,
|
|
148
222
|
job,
|
|
@@ -153,6 +227,7 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
|
153
227
|
interview_todo,
|
|
154
228
|
deadlines,
|
|
155
229
|
followups,
|
|
230
|
+
upcoming_schedule,
|
|
156
231
|
recent_activity: activityRepo.recent(10),
|
|
157
232
|
};
|
|
158
233
|
}
|
|
@@ -160,3 +235,350 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
|
160
235
|
export function listRecentActivity(limit = 20): ActivityRecord[] {
|
|
161
236
|
return activityRepo.recent(limit);
|
|
162
237
|
}
|
|
238
|
+
|
|
239
|
+
/* ----------------------------------------------------------- action digest */
|
|
240
|
+
|
|
241
|
+
// Follow-up is the #1 thing trackers drop (community research): applications go
|
|
242
|
+
// stale and the user forgets to nudge. The dashboard Home already surfaces this,
|
|
243
|
+
// but the AI agent — the primary interface — couldn't see it. getActionDigest
|
|
244
|
+
// exposes the same "what needs my attention now" intelligence to MCP, broadened
|
|
245
|
+
// past 'applied' to the other waiting states (post-pass scheduling, post-interview
|
|
246
|
+
// result). Read-only; thresholds are advisory nudges, not hard rules.
|
|
247
|
+
const POST_PASS_STALE_DAYS = 7; // document_passed → interview scheduling wait
|
|
248
|
+
const POST_INTERVIEW_STALE_DAYS = 7; // interview → result wait
|
|
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
|
+
|
|
258
|
+
export interface FollowupItem {
|
|
259
|
+
job: JobRecord;
|
|
260
|
+
status: ApplicationStatus;
|
|
261
|
+
status_label: string;
|
|
262
|
+
days_since: number;
|
|
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 }[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface ActionDigest {
|
|
291
|
+
/** Server's today (YYYY-MM-DD) — the AI can't reliably know the current date. */
|
|
292
|
+
today: string;
|
|
293
|
+
followups: FollowupItem[];
|
|
294
|
+
deadlines: { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number }[];
|
|
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[];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* "What needs attention now" for the AI agent: stale applications awaiting a
|
|
304
|
+
* response (so the user can decide whether to follow up), upcoming/overdue
|
|
305
|
+
* deadlines, and passed stages still missing interview prep. All read-only.
|
|
306
|
+
*/
|
|
307
|
+
export function getActionDigest(now: Date = new Date()): ActionDigest {
|
|
308
|
+
const applications = applicationRepo.list();
|
|
309
|
+
const jobs = jobRepo.list();
|
|
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));
|
|
314
|
+
|
|
315
|
+
const followups: FollowupItem[] = [];
|
|
316
|
+
for (const a of applications) {
|
|
317
|
+
const job = jobById.get(a.job_id);
|
|
318
|
+
if (!job) continue;
|
|
319
|
+
if (a.status === 'applied') {
|
|
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
|
+
}
|
|
340
|
+
} else if (a.status === 'document_passed') {
|
|
341
|
+
const days = daysSinceTimestamp(a.updated_at, now);
|
|
342
|
+
if (days != null && days >= POST_PASS_STALE_DAYS)
|
|
343
|
+
followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `서류 합격 후 ${days}일째 면접 일정 대기` });
|
|
344
|
+
} else if (a.status === 'interview') {
|
|
345
|
+
const days = daysSinceTimestamp(a.updated_at, now);
|
|
346
|
+
if (days != null && days >= POST_INTERVIEW_STALE_DAYS)
|
|
347
|
+
followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `면접 후 ${days}일째 결과 대기` });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
followups.sort((a, b) => b.days_since - a.days_since);
|
|
351
|
+
|
|
352
|
+
const deadlines = jobs
|
|
353
|
+
.map((job) => {
|
|
354
|
+
const status = statusByJob.get(job.id)?.status ?? 'draft';
|
|
355
|
+
if (!DEADLINE_ACTIONABLE.includes(status)) return null;
|
|
356
|
+
const days_left = daysUntilDate(job.deadline, now);
|
|
357
|
+
if (days_left == null || days_left > DEADLINE_SOON_DAYS) return null;
|
|
358
|
+
return { job, status, status_label: APPLICATION_STATUS_LABELS[status], days_left };
|
|
359
|
+
})
|
|
360
|
+
.filter((x): x is { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number } => x !== null)
|
|
361
|
+
.sort((a, b) => a.days_left - b.days_left);
|
|
362
|
+
|
|
363
|
+
const interview_todo = applications
|
|
364
|
+
.filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !preppedJobIds.has(a.job_id))
|
|
365
|
+
.map((a) => {
|
|
366
|
+
const job = jobById.get(a.job_id);
|
|
367
|
+
return job ? { job, status_label: APPLICATION_STATUS_LABELS[a.status] } : null;
|
|
368
|
+
})
|
|
369
|
+
.filter((x): x is { job: JobRecord; status_label: string } => x !== null);
|
|
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
|
+
|
|
412
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
413
|
+
return { today, followups, deadlines, interview_todo, upcoming_schedule, schedule_conflicts };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* ---------------------------------------------------------- job pre-screen */
|
|
417
|
+
|
|
418
|
+
// "Should I even bother reading this JD?" — community research U3: ~70% of postings
|
|
419
|
+
// prove unsuitable after reading. This is a CHEAP, DETERMINISTIC keyword-overlap
|
|
420
|
+
// signal (same philosophy as charcount: count, don't judge). The real fit analysis
|
|
421
|
+
// is still the AI's job via save_fit_analysis — this only pre-filters by which job
|
|
422
|
+
// keywords the user's stored skills/tech already cover.
|
|
423
|
+
|
|
424
|
+
const normToken = (s: string): string =>
|
|
425
|
+
s.toLowerCase().trim().replace(/^[.,/()\s]+|[.,/()\s]+$/g, '');
|
|
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
|
+
|
|
439
|
+
export interface JobPrescreen {
|
|
440
|
+
job_id: string;
|
|
441
|
+
job_keywords: string[];
|
|
442
|
+
matched: string[];
|
|
443
|
+
missing: string[];
|
|
444
|
+
/** matched / total keywords (0..1); null when the job has no keywords to screen. */
|
|
445
|
+
coverage: number | null;
|
|
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;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function prescreenJob(jobId: string): JobPrescreen | null {
|
|
458
|
+
const job = jobRepo.get(jobId);
|
|
459
|
+
if (!job) return null;
|
|
460
|
+
const userTokens = new Set<string>();
|
|
461
|
+
for (const s of skillRepo.list()) if (s.name) userTokens.add(normToken(s.name));
|
|
462
|
+
for (const e of experienceRepo.list()) for (const t of e.tech ?? []) userTokens.add(normToken(t));
|
|
463
|
+
for (const p of projectRepo.list()) for (const t of p.tech ?? []) userTokens.add(normToken(t));
|
|
464
|
+
userTokens.delete('');
|
|
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
|
+
|
|
471
|
+
const keywords = (job.keywords ?? []).filter((k) => k && k.trim());
|
|
472
|
+
const matched: string[] = [];
|
|
473
|
+
const missing: string[] = [];
|
|
474
|
+
const resume_matched: string[] = [];
|
|
475
|
+
for (const kw of keywords) {
|
|
476
|
+
const n = normToken(kw);
|
|
477
|
+
const skillHit =
|
|
478
|
+
userTokens.has(n) ||
|
|
479
|
+
// substring match only for tokens ≥3 chars (avoid "c"/"r" matching everything).
|
|
480
|
+
(n.length >= 3 && [...userTokens].some((u) => u.length >= 3 && (u.includes(n) || n.includes(u))));
|
|
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);
|
|
485
|
+
}
|
|
486
|
+
const coverage = keywords.length ? matched.length / keywords.length : null;
|
|
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
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/* ----------------------------------------------------------- pipeline stats */
|
|
505
|
+
|
|
506
|
+
// Application analytics (community U1: "what's my response rate?"). A TRUE
|
|
507
|
+
// ever-reached funnel, not a current-status snapshot: status transitions are
|
|
508
|
+
// logged to the timeline (application_status_changed events), so a job currently
|
|
509
|
+
// 'rejected' still counts toward the furthest stage it reached. Descriptive only
|
|
510
|
+
// — with small N these rates are not predictive; the tool surfaces that caveat.
|
|
511
|
+
const PROGRESSION: ApplicationStatus[] = ['applied', 'document_passed', 'interview', 'final_passed'];
|
|
512
|
+
|
|
513
|
+
export interface PipelineStats {
|
|
514
|
+
total: number;
|
|
515
|
+
/** Current-status snapshot (where each application sits right now). */
|
|
516
|
+
by_status: { status: ApplicationStatus; label: string; count: number }[];
|
|
517
|
+
/** Ever-reached counts (reconstructed from the status-change timeline). */
|
|
518
|
+
funnel: { applied: number; document_passed: number; interview: number; final_passed: number };
|
|
519
|
+
/** Pass-through rates relative to "ever applied" (percent, 1 d.p.); null when denominator is 0. */
|
|
520
|
+
rates: { document_pass: number | null; interview: number | null; offer: number | null };
|
|
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;
|
|
526
|
+
note: string;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function getPipelineStats(): PipelineStats {
|
|
530
|
+
const applications = applicationRepo.list();
|
|
531
|
+
const by_status = (Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[]).map((status) => ({
|
|
532
|
+
status,
|
|
533
|
+
label: APPLICATION_STATUS_LABELS[status],
|
|
534
|
+
count: applications.filter((a) => a.status === status).length,
|
|
535
|
+
}));
|
|
536
|
+
|
|
537
|
+
let appliedN = 0, docN = 0, intN = 0, offerN = 0;
|
|
538
|
+
for (const a of applications) {
|
|
539
|
+
const reached = new Set<string>([a.status]);
|
|
540
|
+
if (a.applied_at) reached.add('applied');
|
|
541
|
+
for (const e of timelineRepo.listByJob(a.job_id)) {
|
|
542
|
+
if (e.type !== 'application_status_changed') continue;
|
|
543
|
+
const p = (e.payload ?? {}) as { status?: string; from_status?: string };
|
|
544
|
+
if (p.status) reached.add(p.status);
|
|
545
|
+
if (p.from_status) reached.add(p.from_status);
|
|
546
|
+
}
|
|
547
|
+
let maxStage = -1;
|
|
548
|
+
for (const s of reached) maxStage = Math.max(maxStage, PROGRESSION.indexOf(s as ApplicationStatus));
|
|
549
|
+
if (maxStage >= 0) appliedN++;
|
|
550
|
+
if (maxStage >= 1) docN++;
|
|
551
|
+
if (maxStage >= 2) intN++;
|
|
552
|
+
if (maxStage >= 3) offerN++;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const pct = (n: number, d: number): number | null => (d > 0 ? Math.round((n / d) * 1000) / 10 : null);
|
|
556
|
+
const cur = (s: ApplicationStatus) => applications.filter((a) => a.status === s).length;
|
|
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
|
+
|
|
567
|
+
return {
|
|
568
|
+
total: applications.length,
|
|
569
|
+
by_status,
|
|
570
|
+
funnel: { applied: appliedN, document_passed: docN, interview: intN, final_passed: offerN },
|
|
571
|
+
rates: { document_pass: pct(docN, appliedN), interview: pct(intN, appliedN), offer: pct(offerN, appliedN) },
|
|
572
|
+
outcomes: {
|
|
573
|
+
final_passed: cur('final_passed'),
|
|
574
|
+
rejected: cur('rejected'),
|
|
575
|
+
in_progress: cur('applied') + cur('document_passed') + cur('interview'),
|
|
576
|
+
on_hold: cur('on_hold'),
|
|
577
|
+
},
|
|
578
|
+
rejected_by_stage,
|
|
579
|
+
rejected_unrecorded,
|
|
580
|
+
note:
|
|
581
|
+
'단계별 도달 깔때기는 상태 변경 이력(타임라인)으로 복원한 실측치입니다(현재 rejected여도 거쳐간 최고 단계까지 집계). ' +
|
|
582
|
+
'비율은 설명용이며 표본이 작으면 예측력이 낮습니다 — 추세 참고로만 쓰세요.',
|
|
583
|
+
};
|
|
584
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic character counting for Korean cover letters — the thing a chatbot
|
|
3
|
+
* cannot do reliably. Korean 문항형 자소서 grade on per-question length limits, and
|
|
4
|
+
* the counting rule (with-space / no-space / byte) varies by company; getting it
|
|
5
|
+
* wrong is the single most common disqualification. Pure, LLM-free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type CountMode = 'with_space' | 'no_space' | 'byte';
|
|
9
|
+
|
|
10
|
+
export interface CharCounts {
|
|
11
|
+
/** Code points incl. all whitespace. Uses [...text] so surrogate pairs (emoji) count as 1. */
|
|
12
|
+
withSpace: number;
|
|
13
|
+
/** Code points with every whitespace char (space/tab/newline) removed. */
|
|
14
|
+
noSpace: number;
|
|
15
|
+
/**
|
|
16
|
+
* UTF-8 byte length. NOTE: some legacy Korean forms count 2 bytes per Hangul
|
|
17
|
+
* syllable (EUC-KR/KS X 1001); modern web forms and most "byte" limits today are
|
|
18
|
+
* UTF-8 (3 bytes per Hangul). We report UTF-8 — the AI should confirm the posting's
|
|
19
|
+
* convention when a byte limit is given.
|
|
20
|
+
*/
|
|
21
|
+
byte: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CharLimitCheck {
|
|
25
|
+
mode: CountMode;
|
|
26
|
+
/** The count under the chosen mode. */
|
|
27
|
+
count: number;
|
|
28
|
+
min?: number;
|
|
29
|
+
max?: number;
|
|
30
|
+
/** 'no_limit' when neither min nor max is given. */
|
|
31
|
+
status: 'under' | 'ok' | 'over' | 'no_limit';
|
|
32
|
+
/** over: count−max ; under: min−count ; otherwise undefined. */
|
|
33
|
+
delta?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const utf8Bytes = (text: string): number => {
|
|
37
|
+
// Avoid a Node Buffer dependency in this pure module — count UTF-8 bytes directly.
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
for (const ch of text) {
|
|
40
|
+
const cp = ch.codePointAt(0)!;
|
|
41
|
+
if (cp <= 0x7f) bytes += 1;
|
|
42
|
+
else if (cp <= 0x7ff) bytes += 2;
|
|
43
|
+
else if (cp <= 0xffff) bytes += 3;
|
|
44
|
+
else bytes += 4;
|
|
45
|
+
}
|
|
46
|
+
return bytes;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Count a string three ways. Deterministic, encoding-explicit. */
|
|
50
|
+
export function countChars(text: string): CharCounts {
|
|
51
|
+
const cps = [...text];
|
|
52
|
+
return {
|
|
53
|
+
withSpace: cps.length,
|
|
54
|
+
noSpace: cps.filter((c) => !/\s/.test(c)).length,
|
|
55
|
+
byte: utf8Bytes(text),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pick the count for a mode (default with_space — AGENTS.md: 미지정 시 공백포함). */
|
|
60
|
+
export function countForMode(text: string, mode: CountMode = 'with_space'): number {
|
|
61
|
+
const c = countChars(text);
|
|
62
|
+
return mode === 'no_space' ? c.noSpace : mode === 'byte' ? c.byte : c.withSpace;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check a text against an optional min/max under one counting mode. Advisory —
|
|
67
|
+
* the caller decides what to do with `over`/`under` (this never throws or blocks).
|
|
68
|
+
*/
|
|
69
|
+
export function checkCharLimit(
|
|
70
|
+
text: string,
|
|
71
|
+
opts: { min?: number; max?: number; mode?: CountMode } = {},
|
|
72
|
+
): CharLimitCheck {
|
|
73
|
+
const mode = opts.mode ?? 'with_space';
|
|
74
|
+
const count = countForMode(text, mode);
|
|
75
|
+
const hasMin = typeof opts.min === 'number';
|
|
76
|
+
const hasMax = typeof opts.max === 'number';
|
|
77
|
+
if (!hasMin && !hasMax) {
|
|
78
|
+
return { mode, count, status: 'no_limit' };
|
|
79
|
+
}
|
|
80
|
+
if (hasMax && count > opts.max!) {
|
|
81
|
+
return { mode, count, min: opts.min, max: opts.max, status: 'over', delta: count - opts.max! };
|
|
82
|
+
}
|
|
83
|
+
if (hasMin && count < opts.min!) {
|
|
84
|
+
return { mode, count, min: opts.min, max: opts.max, status: 'under', delta: opts.min! - count };
|
|
85
|
+
}
|
|
86
|
+
return { mode, count, min: opts.min, max: opts.max, status: 'ok' };
|
|
87
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type NumberToken,
|
|
25
25
|
} from './normalize.ts';
|
|
26
26
|
import { STYLE_LEXICONS, countHits } from './lexicons.ts';
|
|
27
|
+
import { countChars, type CharCounts } from './charcount.ts';
|
|
27
28
|
|
|
28
29
|
export type Severity = 'critical' | 'warn' | 'info';
|
|
29
30
|
|
|
@@ -66,7 +67,10 @@ export interface ProvenanceResult {
|
|
|
66
67
|
|
|
67
68
|
export interface LintReport {
|
|
68
69
|
kind: 'cover_letter' | 'fit' | 'interview' | 'resume';
|
|
70
|
+
/** Normalized code-point length (kept for backward compat). For form length use charCounts. */
|
|
69
71
|
charCount: number;
|
|
72
|
+
/** Deterministic counts on the RAW text (what a submission form counts), three Korean conventions. */
|
|
73
|
+
charCounts: CharCounts;
|
|
70
74
|
/** Advisory style signals (never block). */
|
|
71
75
|
signals: Signal[];
|
|
72
76
|
provenance: ProvenanceResult;
|
|
@@ -225,6 +229,7 @@ export function lintArtifact(
|
|
|
225
229
|
return {
|
|
226
230
|
kind,
|
|
227
231
|
charCount: [...norm].length,
|
|
232
|
+
charCounts: countChars(text),
|
|
228
233
|
signals,
|
|
229
234
|
provenance,
|
|
230
235
|
blocking,
|