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.
Files changed (71) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +108 -91
  4. package/THIRD_PARTY_NOTICES.md +2 -1
  5. package/bin/careermate.mjs +11 -2
  6. package/dist/career-os/eop/cover-letter.md +13 -6
  7. package/dist/career-os/eop/interview-prep.md +1 -0
  8. package/dist/career-os/knowledge/cover-letter.md +7 -3
  9. package/dist/career-os/knowledge/fit-matching.md +2 -0
  10. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  11. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  12. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  13. package/dist/doctor.mjs +19760 -1018
  14. package/dist/mcp.mjs +19783 -1035
  15. package/dist/migrate.mjs +289 -20
  16. package/dist/public/i18n/en/applications.js +19 -0
  17. package/dist/public/i18n/en/common.js +10 -0
  18. package/dist/public/i18n/en/documents.js +0 -10
  19. package/dist/public/i18n/en/home.js +5 -0
  20. package/dist/public/i18n/en/jobs.js +18 -1
  21. package/dist/public/i18n/en/profile.js +0 -5
  22. package/dist/public/i18n/en/settings.js +5 -2
  23. package/dist/public/i18n/ko/applications.js +19 -0
  24. package/dist/public/i18n/ko/common.js +9 -0
  25. package/dist/public/i18n/ko/documents.js +0 -10
  26. package/dist/public/i18n/ko/home.js +5 -0
  27. package/dist/public/i18n/ko/jobs.js +18 -1
  28. package/dist/public/i18n/ko/profile.js +0 -5
  29. package/dist/public/i18n/ko/settings.js +5 -2
  30. package/dist/public/lib.js +101 -0
  31. package/dist/public/pages/applications.js +95 -15
  32. package/dist/public/pages/documents.js +20 -14
  33. package/dist/public/pages/home.js +38 -6
  34. package/dist/public/pages/interview.js +11 -4
  35. package/dist/public/pages/jobs.js +71 -1
  36. package/dist/public/pages/profile.js +2 -3
  37. package/dist/public/pages/settings.js +2 -2
  38. package/dist/public/styles.css +52 -4
  39. package/dist/web.mjs +19001 -758
  40. package/docs/FAQ.md +13 -3
  41. package/docs/MCP_TOOLS.md +32 -12
  42. package/docs/career-os/eop/cover-letter.md +13 -6
  43. package/docs/career-os/eop/interview-prep.md +1 -0
  44. package/docs/career-os/knowledge/cover-letter.md +7 -3
  45. package/docs/career-os/knowledge/fit-matching.md +2 -0
  46. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  47. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  48. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  49. package/package.json +5 -2
  50. package/packages/core/src/index.ts +2 -0
  51. package/packages/core/src/jobsearch.ts +190 -0
  52. package/packages/core/src/offers.ts +41 -0
  53. package/packages/core/src/services.ts +29 -3
  54. package/packages/core/src/summary.ts +431 -9
  55. package/packages/core/src/verify/charcount.ts +87 -0
  56. package/packages/core/src/verify/index.ts +1 -0
  57. package/packages/core/src/verify/lint.ts +5 -0
  58. package/packages/db/src/connection.ts +70 -2
  59. package/packages/db/src/index.ts +1 -0
  60. package/packages/db/src/repositories.ts +179 -13
  61. package/packages/db/src/schema.ts +169 -8
  62. package/packages/exporters/src/docx.ts +165 -0
  63. package/packages/exporters/src/index.ts +142 -9
  64. package/packages/exporters/src/markdown.ts +16 -11
  65. package/packages/knowledge/src/index.ts +8 -3
  66. package/packages/mcp-tools/src/result.ts +9 -0
  67. package/packages/mcp-tools/src/tools.ts +303 -60
  68. package/packages/parsers/src/documents.ts +7 -1
  69. package/packages/prompts/src/humanize.ts +4 -0
  70. package/packages/shared/src/schemas.ts +198 -0
  71. 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
- // Suggest a follow-up when an application has sat in 'applied' this many days with no response.
55
- 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;
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) && !interviewRepo.getByJob(a.job_id))
163
+ .filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !preppedJobIds.has(a.job_id))
101
164
  .map((a) => {
102
- const job = jobRepo.get(a.job_id);
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 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.
122
188
  const followups = applications
123
189
  .filter((a) => a.status === 'applied')
124
190
  .map((a) => {
125
- const days_since = daysSinceTimestamp(a.applied_at, now);
126
- if (days_since == null || days_since < FOLLOWUP_STALE_DAYS) return null;
127
- 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);
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 = jobRepo.get(application.job_id);
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
+ }
@@ -8,4 +8,5 @@
8
8
  */
9
9
  export * from './normalize.ts';
10
10
  export * from './lexicons.ts';
11
+ export * from './charcount.ts';
11
12
  export * from './lint.ts';
@@ -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,