careermate 0.0.7 → 0.0.8
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/README.md +30 -10
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/dist/career-os/eop/cover-letter.md +4 -3
- package/dist/career-os/knowledge/cover-letter.md +3 -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/truthfulness.md +1 -0
- package/dist/doctor.mjs +18995 -801
- package/dist/mcp.mjs +19009 -815
- package/dist/migrate.mjs +83 -6
- package/dist/public/i18n/en/common.js +4 -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 +1 -1
- package/dist/public/i18n/en/profile.js +0 -5
- package/dist/public/i18n/ko/common.js +4 -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 +1 -1
- package/dist/public/i18n/ko/profile.js +0 -5
- package/dist/public/lib.js +72 -0
- package/dist/public/pages/applications.js +10 -6
- package/dist/public/pages/documents.js +4 -7
- package/dist/public/pages/home.js +26 -6
- package/dist/public/pages/profile.js +2 -3
- package/dist/public/styles.css +39 -4
- package/dist/web.mjs +18547 -649
- package/docs/MCP_TOOLS.md +14 -7
- package/docs/career-os/eop/cover-letter.md +4 -3
- package/docs/career-os/knowledge/cover-letter.md +3 -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/truthfulness.md +1 -0
- package/package.json +3 -2
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/jobsearch.ts +186 -0
- package/packages/core/src/services.ts +24 -2
- package/packages/core/src/summary.ts +196 -0
- 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/repositories.ts +42 -8
- package/packages/db/src/schema.ts +29 -0
- 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/tools.ts +173 -53
- package/packages/shared/src/schemas.ts +64 -0
- package/scripts/init.ts +5 -0
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
interviewRepo,
|
|
19
19
|
fitRepo,
|
|
20
20
|
activityRepo,
|
|
21
|
+
skillRepo,
|
|
22
|
+
experienceRepo,
|
|
23
|
+
projectRepo,
|
|
24
|
+
timelineRepo,
|
|
21
25
|
} from '@careermate/db';
|
|
22
26
|
import { getOnboardingStatus } from './onboarding.ts';
|
|
23
27
|
|
|
@@ -160,3 +164,195 @@ export function getHomeSummary(now: Date = new Date()): HomeSummary {
|
|
|
160
164
|
export function listRecentActivity(limit = 20): ActivityRecord[] {
|
|
161
165
|
return activityRepo.recent(limit);
|
|
162
166
|
}
|
|
167
|
+
|
|
168
|
+
/* ----------------------------------------------------------- action digest */
|
|
169
|
+
|
|
170
|
+
// Follow-up is the #1 thing trackers drop (community research): applications go
|
|
171
|
+
// stale and the user forgets to nudge. The dashboard Home already surfaces this,
|
|
172
|
+
// but the AI agent — the primary interface — couldn't see it. getActionDigest
|
|
173
|
+
// exposes the same "what needs my attention now" intelligence to MCP, broadened
|
|
174
|
+
// past 'applied' to the other waiting states (post-pass scheduling, post-interview
|
|
175
|
+
// result). Read-only; thresholds are advisory nudges, not hard rules.
|
|
176
|
+
const POST_PASS_STALE_DAYS = 7; // document_passed → interview scheduling wait
|
|
177
|
+
const POST_INTERVIEW_STALE_DAYS = 7; // interview → result wait
|
|
178
|
+
|
|
179
|
+
export interface FollowupItem {
|
|
180
|
+
job: JobRecord;
|
|
181
|
+
status: ApplicationStatus;
|
|
182
|
+
status_label: string;
|
|
183
|
+
days_since: number;
|
|
184
|
+
reason: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ActionDigest {
|
|
188
|
+
/** Server's today (YYYY-MM-DD) — the AI can't reliably know the current date. */
|
|
189
|
+
today: string;
|
|
190
|
+
followups: FollowupItem[];
|
|
191
|
+
deadlines: { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number }[];
|
|
192
|
+
interview_todo: { job: JobRecord; status_label: string }[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* "What needs attention now" for the AI agent: stale applications awaiting a
|
|
197
|
+
* response (so the user can decide whether to follow up), upcoming/overdue
|
|
198
|
+
* deadlines, and passed stages still missing interview prep. All read-only.
|
|
199
|
+
*/
|
|
200
|
+
export function getActionDigest(now: Date = new Date()): ActionDigest {
|
|
201
|
+
const applications = applicationRepo.list();
|
|
202
|
+
const jobs = jobRepo.list();
|
|
203
|
+
const statusByJob = new Map(applications.map((a) => [a.job_id, a]));
|
|
204
|
+
|
|
205
|
+
const followups: FollowupItem[] = [];
|
|
206
|
+
for (const a of applications) {
|
|
207
|
+
const job = jobRepo.get(a.job_id);
|
|
208
|
+
if (!job) continue;
|
|
209
|
+
if (a.status === 'applied') {
|
|
210
|
+
const days = daysSinceTimestamp(a.applied_at, now);
|
|
211
|
+
if (days != null && days >= FOLLOWUP_STALE_DAYS)
|
|
212
|
+
followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `지원 후 ${days}일째 응답 없음 — 정중한 후속 문의를 고려할 시점` });
|
|
213
|
+
} else if (a.status === 'document_passed') {
|
|
214
|
+
const days = daysSinceTimestamp(a.updated_at, now);
|
|
215
|
+
if (days != null && days >= POST_PASS_STALE_DAYS)
|
|
216
|
+
followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `서류 합격 후 ${days}일째 면접 일정 대기` });
|
|
217
|
+
} else if (a.status === 'interview') {
|
|
218
|
+
const days = daysSinceTimestamp(a.updated_at, now);
|
|
219
|
+
if (days != null && days >= POST_INTERVIEW_STALE_DAYS)
|
|
220
|
+
followups.push({ job, status: a.status, status_label: APPLICATION_STATUS_LABELS[a.status], days_since: days, reason: `면접 후 ${days}일째 결과 대기` });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
followups.sort((a, b) => b.days_since - a.days_since);
|
|
224
|
+
|
|
225
|
+
const deadlines = jobs
|
|
226
|
+
.map((job) => {
|
|
227
|
+
const status = statusByJob.get(job.id)?.status ?? 'draft';
|
|
228
|
+
if (!DEADLINE_ACTIONABLE.includes(status)) return null;
|
|
229
|
+
const days_left = daysUntilDate(job.deadline, now);
|
|
230
|
+
if (days_left == null || days_left > DEADLINE_SOON_DAYS) return null;
|
|
231
|
+
return { job, status, status_label: APPLICATION_STATUS_LABELS[status], days_left };
|
|
232
|
+
})
|
|
233
|
+
.filter((x): x is { job: JobRecord; status: ApplicationStatus; status_label: string; days_left: number } => x !== null)
|
|
234
|
+
.sort((a, b) => a.days_left - b.days_left);
|
|
235
|
+
|
|
236
|
+
const interview_todo = applications
|
|
237
|
+
.filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status) && !interviewRepo.getByJob(a.job_id))
|
|
238
|
+
.map((a) => {
|
|
239
|
+
const job = jobRepo.get(a.job_id);
|
|
240
|
+
return job ? { job, status_label: APPLICATION_STATUS_LABELS[a.status] } : null;
|
|
241
|
+
})
|
|
242
|
+
.filter((x): x is { job: JobRecord; status_label: string } => x !== null);
|
|
243
|
+
|
|
244
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
245
|
+
return { today, followups, deadlines, interview_todo };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ---------------------------------------------------------- job pre-screen */
|
|
249
|
+
|
|
250
|
+
// "Should I even bother reading this JD?" — community research U3: ~70% of postings
|
|
251
|
+
// prove unsuitable after reading. This is a CHEAP, DETERMINISTIC keyword-overlap
|
|
252
|
+
// signal (same philosophy as charcount: count, don't judge). The real fit analysis
|
|
253
|
+
// is still the AI's job via save_fit_analysis — this only pre-filters by which job
|
|
254
|
+
// keywords the user's stored skills/tech already cover.
|
|
255
|
+
|
|
256
|
+
const normToken = (s: string): string =>
|
|
257
|
+
s.toLowerCase().trim().replace(/^[.,/()\s]+|[.,/()\s]+$/g, '');
|
|
258
|
+
|
|
259
|
+
export interface JobPrescreen {
|
|
260
|
+
job_id: string;
|
|
261
|
+
job_keywords: string[];
|
|
262
|
+
matched: string[];
|
|
263
|
+
missing: string[];
|
|
264
|
+
/** matched / total keywords (0..1); null when the job has no keywords to screen. */
|
|
265
|
+
coverage: number | null;
|
|
266
|
+
user_skill_count: number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function prescreenJob(jobId: string): JobPrescreen | null {
|
|
270
|
+
const job = jobRepo.get(jobId);
|
|
271
|
+
if (!job) return null;
|
|
272
|
+
const userTokens = new Set<string>();
|
|
273
|
+
for (const s of skillRepo.list()) if (s.name) userTokens.add(normToken(s.name));
|
|
274
|
+
for (const e of experienceRepo.list()) for (const t of e.tech ?? []) userTokens.add(normToken(t));
|
|
275
|
+
for (const p of projectRepo.list()) for (const t of p.tech ?? []) userTokens.add(normToken(t));
|
|
276
|
+
userTokens.delete('');
|
|
277
|
+
|
|
278
|
+
const keywords = (job.keywords ?? []).filter((k) => k && k.trim());
|
|
279
|
+
const matched: string[] = [];
|
|
280
|
+
const missing: string[] = [];
|
|
281
|
+
for (const kw of keywords) {
|
|
282
|
+
const n = normToken(kw);
|
|
283
|
+
const hit =
|
|
284
|
+
userTokens.has(n) ||
|
|
285
|
+
// substring match only for tokens ≥3 chars (avoid "c"/"r" matching everything).
|
|
286
|
+
(n.length >= 3 && [...userTokens].some((u) => u.length >= 3 && (u.includes(n) || n.includes(u))));
|
|
287
|
+
(hit ? matched : missing).push(kw);
|
|
288
|
+
}
|
|
289
|
+
const coverage = keywords.length ? matched.length / keywords.length : null;
|
|
290
|
+
return { job_id: jobId, job_keywords: keywords, matched, missing, coverage, user_skill_count: userTokens.size };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ----------------------------------------------------------- pipeline stats */
|
|
294
|
+
|
|
295
|
+
// Application analytics (community U1: "what's my response rate?"). A TRUE
|
|
296
|
+
// ever-reached funnel, not a current-status snapshot: status transitions are
|
|
297
|
+
// logged to the timeline (application_status_changed events), so a job currently
|
|
298
|
+
// 'rejected' still counts toward the furthest stage it reached. Descriptive only
|
|
299
|
+
// — with small N these rates are not predictive; the tool surfaces that caveat.
|
|
300
|
+
const PROGRESSION: ApplicationStatus[] = ['applied', 'document_passed', 'interview', 'final_passed'];
|
|
301
|
+
|
|
302
|
+
export interface PipelineStats {
|
|
303
|
+
total: number;
|
|
304
|
+
/** Current-status snapshot (where each application sits right now). */
|
|
305
|
+
by_status: { status: ApplicationStatus; label: string; count: number }[];
|
|
306
|
+
/** Ever-reached counts (reconstructed from the status-change timeline). */
|
|
307
|
+
funnel: { applied: number; document_passed: number; interview: number; final_passed: number };
|
|
308
|
+
/** Pass-through rates relative to "ever applied" (percent, 1 d.p.); null when denominator is 0. */
|
|
309
|
+
rates: { document_pass: number | null; interview: number | null; offer: number | null };
|
|
310
|
+
outcomes: { final_passed: number; rejected: number; in_progress: number; on_hold: number };
|
|
311
|
+
note: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function getPipelineStats(): PipelineStats {
|
|
315
|
+
const applications = applicationRepo.list();
|
|
316
|
+
const by_status = (Object.keys(APPLICATION_STATUS_LABELS) as ApplicationStatus[]).map((status) => ({
|
|
317
|
+
status,
|
|
318
|
+
label: APPLICATION_STATUS_LABELS[status],
|
|
319
|
+
count: applications.filter((a) => a.status === status).length,
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
let appliedN = 0, docN = 0, intN = 0, offerN = 0;
|
|
323
|
+
for (const a of applications) {
|
|
324
|
+
const reached = new Set<string>([a.status]);
|
|
325
|
+
if (a.applied_at) reached.add('applied');
|
|
326
|
+
for (const e of timelineRepo.listByJob(a.job_id)) {
|
|
327
|
+
if (e.type !== 'application_status_changed') continue;
|
|
328
|
+
const p = (e.payload ?? {}) as { status?: string; from_status?: string };
|
|
329
|
+
if (p.status) reached.add(p.status);
|
|
330
|
+
if (p.from_status) reached.add(p.from_status);
|
|
331
|
+
}
|
|
332
|
+
let maxStage = -1;
|
|
333
|
+
for (const s of reached) maxStage = Math.max(maxStage, PROGRESSION.indexOf(s as ApplicationStatus));
|
|
334
|
+
if (maxStage >= 0) appliedN++;
|
|
335
|
+
if (maxStage >= 1) docN++;
|
|
336
|
+
if (maxStage >= 2) intN++;
|
|
337
|
+
if (maxStage >= 3) offerN++;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const pct = (n: number, d: number): number | null => (d > 0 ? Math.round((n / d) * 1000) / 10 : null);
|
|
341
|
+
const cur = (s: ApplicationStatus) => applications.filter((a) => a.status === s).length;
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
total: applications.length,
|
|
345
|
+
by_status,
|
|
346
|
+
funnel: { applied: appliedN, document_passed: docN, interview: intN, final_passed: offerN },
|
|
347
|
+
rates: { document_pass: pct(docN, appliedN), interview: pct(intN, appliedN), offer: pct(offerN, appliedN) },
|
|
348
|
+
outcomes: {
|
|
349
|
+
final_passed: cur('final_passed'),
|
|
350
|
+
rejected: cur('rejected'),
|
|
351
|
+
in_progress: cur('applied') + cur('document_passed') + cur('interview'),
|
|
352
|
+
on_hold: cur('on_hold'),
|
|
353
|
+
},
|
|
354
|
+
note:
|
|
355
|
+
'단계별 도달 깔때기는 상태 변경 이력(타임라인)으로 복원한 실측치입니다(현재 rejected여도 거쳐간 최고 단계까지 집계). ' +
|
|
356
|
+
'비율은 설명용이며 표본이 작으면 예측력이 낮습니다 — 추세 참고로만 쓰세요.',
|
|
357
|
+
};
|
|
358
|
+
}
|
|
@@ -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,
|
|
@@ -64,6 +64,10 @@ function mapProfile(r: any): ProfileRecord {
|
|
|
64
64
|
preferred_tone: r.preferred_tone,
|
|
65
65
|
emphasis_points: fromJson(r.emphasis_points, []),
|
|
66
66
|
links: fromJson(r.links, []),
|
|
67
|
+
education: fromJson(r.education, []),
|
|
68
|
+
certifications: fromJson(r.certifications, []),
|
|
69
|
+
language_scores: fromJson(r.language_scores, []),
|
|
70
|
+
awards: fromJson(r.awards, []),
|
|
67
71
|
created_at: r.created_at,
|
|
68
72
|
updated_at: r.updated_at,
|
|
69
73
|
};
|
|
@@ -92,21 +96,31 @@ export const profileRepo = {
|
|
|
92
96
|
preferred_tone: input.preferred_tone ?? existing?.preferred_tone ?? null,
|
|
93
97
|
emphasis_points: input.emphasis_points ?? existing?.emphasis_points ?? [],
|
|
94
98
|
links: input.links ?? existing?.links ?? [],
|
|
99
|
+
education: input.education ?? existing?.education ?? [],
|
|
100
|
+
certifications: input.certifications ?? existing?.certifications ?? [],
|
|
101
|
+
language_scores: input.language_scores ?? existing?.language_scores ?? [],
|
|
102
|
+
awards: input.awards ?? existing?.awards ?? [],
|
|
95
103
|
created_at: existing?.created_at ?? ts,
|
|
96
104
|
updated_at: ts,
|
|
97
105
|
};
|
|
98
106
|
db.prepare(
|
|
99
|
-
`INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,created_at,updated_at)
|
|
100
|
-
VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@created_at,@updated_at)
|
|
107
|
+
`INSERT INTO profile (id,name,email,phone,location,headline,summary,desired_roles,desired_conditions,preferred_tone,emphasis_points,links,education,certifications,language_scores,awards,created_at,updated_at)
|
|
108
|
+
VALUES (@id,@name,@email,@phone,@location,@headline,@summary,@desired_roles,@desired_conditions,@preferred_tone,@emphasis_points,@links,@education,@certifications,@language_scores,@awards,@created_at,@updated_at)
|
|
101
109
|
ON CONFLICT(id) DO UPDATE SET
|
|
102
110
|
name=@name,email=@email,phone=@phone,location=@location,headline=@headline,summary=@summary,
|
|
103
111
|
desired_roles=@desired_roles,desired_conditions=@desired_conditions,preferred_tone=@preferred_tone,
|
|
104
|
-
emphasis_points=@emphasis_points,links=@links,
|
|
112
|
+
emphasis_points=@emphasis_points,links=@links,
|
|
113
|
+
education=@education,certifications=@certifications,language_scores=@language_scores,awards=@awards,
|
|
114
|
+
updated_at=@updated_at`,
|
|
105
115
|
).run({
|
|
106
116
|
...merged,
|
|
107
117
|
desired_roles: toJson(merged.desired_roles),
|
|
108
118
|
emphasis_points: toJson(merged.emphasis_points),
|
|
109
119
|
links: toJson(merged.links),
|
|
120
|
+
education: toJson(merged.education),
|
|
121
|
+
certifications: toJson(merged.certifications),
|
|
122
|
+
language_scores: toJson(merged.language_scores),
|
|
123
|
+
awards: toJson(merged.awards),
|
|
110
124
|
});
|
|
111
125
|
return merged;
|
|
112
126
|
},
|
|
@@ -642,6 +656,9 @@ function mapJob(r: any): JobRecord {
|
|
|
642
656
|
keywords: fromJson(r.keywords, []),
|
|
643
657
|
deadline: r.deadline,
|
|
644
658
|
source: r.source,
|
|
659
|
+
company_overview: r.company_overview ?? null,
|
|
660
|
+
talent_profile: r.talent_profile ?? null,
|
|
661
|
+
core_values: fromJson(r.core_values, []),
|
|
645
662
|
created_at: r.created_at,
|
|
646
663
|
updated_at: r.updated_at,
|
|
647
664
|
};
|
|
@@ -674,7 +691,7 @@ export const jobRepo = {
|
|
|
674
691
|
if (existing) {
|
|
675
692
|
const m = { ...existing, ...input } as JobRecord;
|
|
676
693
|
db.prepare(
|
|
677
|
-
`UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,updated_at=? WHERE id=?`,
|
|
694
|
+
`UPDATE jobs SET company=?,position=?,url=?,location=?,employment_type=?,description=?,requirements=?,keywords=?,deadline=?,source=?,company_overview=?,talent_profile=?,core_values=?,updated_at=? WHERE id=?`,
|
|
678
695
|
).run(
|
|
679
696
|
m.company,
|
|
680
697
|
m.position,
|
|
@@ -686,6 +703,9 @@ export const jobRepo = {
|
|
|
686
703
|
toJson(m.keywords ?? []),
|
|
687
704
|
m.deadline ?? null,
|
|
688
705
|
m.source ?? null,
|
|
706
|
+
m.company_overview ?? null,
|
|
707
|
+
m.talent_profile ?? null,
|
|
708
|
+
toJson(m.core_values ?? []),
|
|
689
709
|
ts,
|
|
690
710
|
existing.id,
|
|
691
711
|
);
|
|
@@ -693,8 +713,8 @@ export const jobRepo = {
|
|
|
693
713
|
}
|
|
694
714
|
const newJobId = id ?? newId('job_');
|
|
695
715
|
db.prepare(
|
|
696
|
-
`INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,created_at,updated_at)
|
|
697
|
-
VALUES (
|
|
716
|
+
`INSERT INTO jobs (id,company,position,url,location,employment_type,description,requirements,keywords,deadline,source,company_overview,talent_profile,core_values,created_at,updated_at)
|
|
717
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
698
718
|
).run(
|
|
699
719
|
newJobId,
|
|
700
720
|
input.company,
|
|
@@ -707,6 +727,9 @@ export const jobRepo = {
|
|
|
707
727
|
toJson(input.keywords ?? []),
|
|
708
728
|
input.deadline ?? null,
|
|
709
729
|
input.source ?? null,
|
|
730
|
+
input.company_overview ?? null,
|
|
731
|
+
input.talent_profile ?? null,
|
|
732
|
+
toJson(input.core_values ?? []),
|
|
710
733
|
ts,
|
|
711
734
|
ts,
|
|
712
735
|
);
|
|
@@ -872,6 +895,9 @@ export const applicationRepo = {
|
|
|
872
895
|
/* --------------------------------------------------------- Interview preps */
|
|
873
896
|
|
|
874
897
|
function mapInterviewPrep(r: any): InterviewPrepRecord {
|
|
898
|
+
// debrief defaults to '{}' at the DB level; surface an empty debrief as null so
|
|
899
|
+
// consumers can treat "no debrief yet" uniformly.
|
|
900
|
+
const debrief = fromJson(r.debrief, null);
|
|
875
901
|
return {
|
|
876
902
|
id: r.id,
|
|
877
903
|
job_id: r.job_id,
|
|
@@ -879,6 +905,7 @@ function mapInterviewPrep(r: any): InterviewPrepRecord {
|
|
|
879
905
|
star_guides: fromJson(r.star_guides, []),
|
|
880
906
|
self_introduction: r.self_introduction,
|
|
881
907
|
notes: r.notes,
|
|
908
|
+
debrief: debrief && Object.keys(debrief).length ? debrief : null,
|
|
882
909
|
created_at: r.created_at,
|
|
883
910
|
updated_at: r.updated_at,
|
|
884
911
|
};
|
|
@@ -900,13 +927,19 @@ export const interviewRepo = {
|
|
|
900
927
|
const ts = now();
|
|
901
928
|
if (existing) {
|
|
902
929
|
const m = { ...existing, ...input } as InterviewPrepRecord;
|
|
930
|
+
// Debrief is shallow-merged (not replaced) so a later partial debrief save
|
|
931
|
+
// doesn't wipe earlier fields; prep (pre) and debrief (post) coexist per job.
|
|
932
|
+
const mergedDebrief = input.debrief
|
|
933
|
+
? { ...(existing.debrief ?? {}), ...input.debrief }
|
|
934
|
+
: existing.debrief;
|
|
903
935
|
db.prepare(
|
|
904
|
-
`UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,updated_at=? WHERE id=?`,
|
|
936
|
+
`UPDATE interview_preps SET questions=?,star_guides=?,self_introduction=?,notes=?,debrief=?,updated_at=? WHERE id=?`,
|
|
905
937
|
).run(
|
|
906
938
|
toJson(m.questions ?? []),
|
|
907
939
|
toJson(m.star_guides ?? []),
|
|
908
940
|
m.self_introduction ?? null,
|
|
909
941
|
m.notes ?? null,
|
|
942
|
+
toJson(mergedDebrief ?? {}),
|
|
910
943
|
ts,
|
|
911
944
|
existing.id,
|
|
912
945
|
);
|
|
@@ -914,7 +947,7 @@ export const interviewRepo = {
|
|
|
914
947
|
}
|
|
915
948
|
const id = newId('itv_');
|
|
916
949
|
db.prepare(
|
|
917
|
-
`INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,created_at,updated_at) VALUES (
|
|
950
|
+
`INSERT INTO interview_preps (id,job_id,questions,star_guides,self_introduction,notes,debrief,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)`,
|
|
918
951
|
).run(
|
|
919
952
|
id,
|
|
920
953
|
input.job_id,
|
|
@@ -922,6 +955,7 @@ export const interviewRepo = {
|
|
|
922
955
|
toJson(input.star_guides ?? []),
|
|
923
956
|
input.self_introduction ?? null,
|
|
924
957
|
input.notes ?? null,
|
|
958
|
+
toJson(input.debrief ?? {}),
|
|
925
959
|
ts,
|
|
926
960
|
ts,
|
|
927
961
|
);
|
|
@@ -300,6 +300,35 @@ export const MIGRATIONS: string[] = [
|
|
|
300
300
|
|
|
301
301
|
CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
|
|
302
302
|
`,
|
|
303
|
+
|
|
304
|
+
// v4 — structured quantitative credentials on the profile (학력·자격증·어학점수·수상).
|
|
305
|
+
// Stored as JSON arrays in TEXT columns (same pattern as profile.links), so the AI
|
|
306
|
+
// can cite exact values (GPA, TOEIC 920) without re-asking or hallucinating, and
|
|
307
|
+
// they feed the verify corpus. ALTER ADD COLUMN is non-destructive and idempotent
|
|
308
|
+
// across the ordered migration runner.
|
|
309
|
+
`
|
|
310
|
+
ALTER TABLE profile ADD COLUMN education TEXT NOT NULL DEFAULT '[]';
|
|
311
|
+
ALTER TABLE profile ADD COLUMN certifications TEXT NOT NULL DEFAULT '[]';
|
|
312
|
+
ALTER TABLE profile ADD COLUMN language_scores TEXT NOT NULL DEFAULT '[]';
|
|
313
|
+
ALTER TABLE profile ADD COLUMN awards TEXT NOT NULL DEFAULT '[]';
|
|
314
|
+
`,
|
|
315
|
+
|
|
316
|
+
// v5 — company research on the job posting (사업개요·인재상·핵심가치). Korea-centric: cover
|
|
317
|
+
// letters and interview prep align to a company's 인재상/핵심가치, so store it where the AI
|
|
318
|
+
// already reads it (the job context) rather than a separate entity. Additive ALTER, idempotent.
|
|
319
|
+
`
|
|
320
|
+
ALTER TABLE jobs ADD COLUMN company_overview TEXT;
|
|
321
|
+
ALTER TABLE jobs ADD COLUMN talent_profile TEXT;
|
|
322
|
+
ALTER TABLE jobs ADD COLUMN core_values TEXT NOT NULL DEFAULT '[]';
|
|
323
|
+
`,
|
|
324
|
+
|
|
325
|
+
// v6 — post-interview debrief on interview_preps (받은 질문·잘된 점·개선점·다음 라운드 포커스·
|
|
326
|
+
// 체감 분위기). Closes the loop: prep is pre-interview, debrief is post-interview, but both belong
|
|
327
|
+
// to the same per-job entity, so store as one JSON column rather than a new table. Additive ALTER,
|
|
328
|
+
// idempotent. Feeds next-round prep and consistency (lessons carry into later interviews).
|
|
329
|
+
`
|
|
330
|
+
ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
|
|
331
|
+
`,
|
|
303
332
|
];
|
|
304
333
|
|
|
305
334
|
export function migrate(db: DatabaseSync): { from: number; to: number } {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → .docx renderer.
|
|
3
|
+
*
|
|
4
|
+
* The other exporters already produce a small, well-known Markdown dialect
|
|
5
|
+
* (headings `#`…`####`, `- ` bullets, `**label**: value` rows, `[text](url)`
|
|
6
|
+
* links, `**bold**`, `_italic_`, `---` rules). Rather than re-derive structure
|
|
7
|
+
* from records per document type, we render THAT dialect to a Word document —
|
|
8
|
+
* exactly mirroring how `html.ts` turns the same Markdown into print HTML. One
|
|
9
|
+
* faithful, ATS-safe single-column layout; no decorative template.
|
|
10
|
+
*
|
|
11
|
+
* Binary output is a `Uint8Array` (the OOXML zip). Callers stream/write it as-is.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
Document,
|
|
15
|
+
ExternalHyperlink,
|
|
16
|
+
HeadingLevel,
|
|
17
|
+
Packer,
|
|
18
|
+
Paragraph,
|
|
19
|
+
TextRun,
|
|
20
|
+
} from 'docx';
|
|
21
|
+
|
|
22
|
+
const HEADING_BY_LEVEL = [
|
|
23
|
+
HeadingLevel.HEADING_1,
|
|
24
|
+
HeadingLevel.HEADING_2,
|
|
25
|
+
HeadingLevel.HEADING_3,
|
|
26
|
+
HeadingLevel.HEADING_4,
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* XML 1.0 forbids these control chars even as numeric references; the `docx`
|
|
31
|
+
* lib writes run text verbatim into document.xml, so a single one makes the
|
|
32
|
+
* whole .docx not well-formed and Word refuses to open it. Tab (0x09) and
|
|
33
|
+
* newline (0x0A) are valid and preserved. Strip everything else in the C0 set.
|
|
34
|
+
*/
|
|
35
|
+
const XML_INVALID_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
|
|
36
|
+
const xmlSafe = (s: string): string => s.replace(XML_INVALID_RE, '');
|
|
37
|
+
|
|
38
|
+
const ALNUM_RE = /[\p{L}\p{N}]/u;
|
|
39
|
+
/** Link-scheme allowlist — mirrors html.ts renderInline so the docx path can't
|
|
40
|
+
* emit javascript:/file:// hyperlinks that the HTML path neutralizes. */
|
|
41
|
+
const SAFE_LINK_RE = /^(https?:|mailto:|#|\/)/i;
|
|
42
|
+
|
|
43
|
+
/** Inline tokens: **bold**, _italic_, [text](url). Order-preserving, non-nesting. */
|
|
44
|
+
const INLINE_RE = /(\*\*([^*]+)\*\*|_([^_]+)_|\[([^\]]+)\]\(([^)]+)\))/g;
|
|
45
|
+
|
|
46
|
+
function inlineRuns(text: string): (TextRun | ExternalHyperlink)[] {
|
|
47
|
+
const src = xmlSafe(text);
|
|
48
|
+
const out: (TextRun | ExternalHyperlink)[] = [];
|
|
49
|
+
let last = 0;
|
|
50
|
+
for (const m of src.matchAll(INLINE_RE)) {
|
|
51
|
+
const idx = m.index ?? 0;
|
|
52
|
+
if (idx > last) out.push(new TextRun(src.slice(last, idx)));
|
|
53
|
+
if (m[2] !== undefined) {
|
|
54
|
+
out.push(new TextRun({ text: m[2], bold: true }));
|
|
55
|
+
} else if (m[3] !== undefined) {
|
|
56
|
+
// CommonMark intraword-underscore rule: `_` is emphasis only when flanked
|
|
57
|
+
// by non-alphanumerics, so snake_case / env-vars / file_paths
|
|
58
|
+
// (read_write_latency, API_KEY) are kept literal instead of being mangled.
|
|
59
|
+
const before = idx > 0 ? src[idx - 1] : '';
|
|
60
|
+
const after = src[idx + m[0].length] ?? '';
|
|
61
|
+
if (ALNUM_RE.test(before) || ALNUM_RE.test(after)) {
|
|
62
|
+
out.push(new TextRun(m[0])); // not emphasis — emit underscores literally
|
|
63
|
+
} else {
|
|
64
|
+
out.push(new TextRun({ text: m[3], italics: true }));
|
|
65
|
+
}
|
|
66
|
+
} else if (m[4] !== undefined) {
|
|
67
|
+
// [text](url) — a real Word hyperlink so it survives ATS text extraction.
|
|
68
|
+
const link = (m[5] ?? '').trim();
|
|
69
|
+
if (SAFE_LINK_RE.test(link)) {
|
|
70
|
+
out.push(
|
|
71
|
+
new ExternalHyperlink({
|
|
72
|
+
children: [new TextRun({ text: m[4], style: 'Hyperlink' })],
|
|
73
|
+
link,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
// Disallowed scheme (javascript:/file:/data:) → plain text, mirroring html.ts.
|
|
78
|
+
out.push(new TextRun(m[4]));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
last = idx + m[0].length;
|
|
82
|
+
}
|
|
83
|
+
if (last < src.length) out.push(new TextRun(src.slice(last)));
|
|
84
|
+
return out.length ? out : [new TextRun('')];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse our Markdown dialect (blocks split on blank lines) into docx paragraphs. */
|
|
88
|
+
function markdownToParagraphs(markdown: string): Paragraph[] {
|
|
89
|
+
const paras: Paragraph[] = [];
|
|
90
|
+
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
91
|
+
|
|
92
|
+
for (const raw of lines) {
|
|
93
|
+
const line = raw.trimEnd();
|
|
94
|
+
if (line.trim() === '') continue; // blank lines: spacing comes from paragraph config
|
|
95
|
+
|
|
96
|
+
// Horizontal rule → a thin bottom border on an empty paragraph.
|
|
97
|
+
if (/^---+$/.test(line.trim())) {
|
|
98
|
+
paras.push(
|
|
99
|
+
new Paragraph({
|
|
100
|
+
border: { bottom: { style: 'single', size: 6, space: 1, color: 'CCCCCC' } },
|
|
101
|
+
spacing: { after: 160 },
|
|
102
|
+
children: [new TextRun('')],
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ATX heading.
|
|
109
|
+
const h = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
110
|
+
if (h) {
|
|
111
|
+
const level = h[1].length - 1;
|
|
112
|
+
paras.push(
|
|
113
|
+
new Paragraph({
|
|
114
|
+
heading: HEADING_BY_LEVEL[level],
|
|
115
|
+
spacing: { before: level === 0 ? 0 : 200, after: 80 },
|
|
116
|
+
children: inlineRuns(h[2]),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Bullet (-, *, + — match the markdown dialect html.ts accepts).
|
|
123
|
+
const b = /^[-*+]\s+(.*)$/.exec(line);
|
|
124
|
+
if (b) {
|
|
125
|
+
paras.push(
|
|
126
|
+
new Paragraph({ bullet: { level: 0 }, spacing: { after: 40 }, children: inlineRuns(b[1]) }),
|
|
127
|
+
);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
paras.push(new Paragraph({ spacing: { after: 120 }, children: inlineRuns(line) }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return paras;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const DOCX_FONT = '맑은 고딕'; // Malgun Gothic — ships with Windows, renders Korean + Latin cleanly.
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Render a title + our Markdown body to a .docx byte array (single-column,
|
|
141
|
+
* ATS-friendly). The leading `# title` in the body is rendered as Heading 1, so
|
|
142
|
+
* we don't duplicate the title here.
|
|
143
|
+
*/
|
|
144
|
+
export async function markdownToDocx(_title: string, markdown: string): Promise<Uint8Array> {
|
|
145
|
+
const doc = new Document({
|
|
146
|
+
styles: {
|
|
147
|
+
default: {
|
|
148
|
+
document: { run: { font: DOCX_FONT, size: 22 } }, // 11pt
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
sections: [
|
|
152
|
+
{
|
|
153
|
+
properties: {
|
|
154
|
+
page: { margin: { top: 1134, bottom: 1134, left: 1134, right: 1134 } }, // 2cm
|
|
155
|
+
},
|
|
156
|
+
children: markdownToParagraphs(markdown),
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
// Packer.toBuffer returns a Node Buffer (a Uint8Array subclass) in Node.
|
|
161
|
+
return Packer.toBuffer(doc);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const DOCX_MIME =
|
|
165
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|