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.
Files changed (50) hide show
  1. package/README.md +30 -10
  2. package/THIRD_PARTY_NOTICES.md +2 -1
  3. package/dist/career-os/eop/cover-letter.md +4 -3
  4. package/dist/career-os/knowledge/cover-letter.md +3 -3
  5. package/dist/career-os/knowledge/fit-matching.md +2 -0
  6. package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  7. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
  8. package/dist/doctor.mjs +18995 -801
  9. package/dist/mcp.mjs +19009 -815
  10. package/dist/migrate.mjs +83 -6
  11. package/dist/public/i18n/en/common.js +4 -0
  12. package/dist/public/i18n/en/documents.js +0 -10
  13. package/dist/public/i18n/en/home.js +5 -0
  14. package/dist/public/i18n/en/jobs.js +1 -1
  15. package/dist/public/i18n/en/profile.js +0 -5
  16. package/dist/public/i18n/ko/common.js +4 -0
  17. package/dist/public/i18n/ko/documents.js +0 -10
  18. package/dist/public/i18n/ko/home.js +5 -0
  19. package/dist/public/i18n/ko/jobs.js +1 -1
  20. package/dist/public/i18n/ko/profile.js +0 -5
  21. package/dist/public/lib.js +72 -0
  22. package/dist/public/pages/applications.js +10 -6
  23. package/dist/public/pages/documents.js +4 -7
  24. package/dist/public/pages/home.js +26 -6
  25. package/dist/public/pages/profile.js +2 -3
  26. package/dist/public/styles.css +39 -4
  27. package/dist/web.mjs +18547 -649
  28. package/docs/MCP_TOOLS.md +14 -7
  29. package/docs/career-os/eop/cover-letter.md +4 -3
  30. package/docs/career-os/knowledge/cover-letter.md +3 -3
  31. package/docs/career-os/knowledge/fit-matching.md +2 -0
  32. package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
  33. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
  34. package/package.json +3 -2
  35. package/packages/core/src/index.ts +1 -0
  36. package/packages/core/src/jobsearch.ts +186 -0
  37. package/packages/core/src/services.ts +24 -2
  38. package/packages/core/src/summary.ts +196 -0
  39. package/packages/core/src/verify/charcount.ts +87 -0
  40. package/packages/core/src/verify/index.ts +1 -0
  41. package/packages/core/src/verify/lint.ts +5 -0
  42. package/packages/db/src/repositories.ts +42 -8
  43. package/packages/db/src/schema.ts +29 -0
  44. package/packages/exporters/src/docx.ts +165 -0
  45. package/packages/exporters/src/index.ts +142 -9
  46. package/packages/exporters/src/markdown.ts +16 -11
  47. package/packages/knowledge/src/index.ts +8 -3
  48. package/packages/mcp-tools/src/tools.ts +173 -53
  49. package/packages/shared/src/schemas.ts +64 -0
  50. package/scripts/init.ts +5 -0
@@ -28,6 +28,7 @@ import {
28
28
  slugify,
29
29
  } from './markdown.ts';
30
30
  import { escapeHtml, toPrintableHtml } from './html.ts';
31
+ import { markdownToDocx, DOCX_MIME } from './docx.ts';
31
32
 
32
33
  /** The uniform shape every exporter returns. */
33
34
  export interface ExportResult {
@@ -35,8 +36,16 @@ export interface ExportResult {
35
36
  filename: string;
36
37
  /** MIME type, e.g. `text/markdown` or `text/html`. */
37
38
  mimeType: string;
38
- /** The document body. */
39
+ /**
40
+ * The document body as text. For binary formats (`.docx`) this is a short,
41
+ * human-readable note instead of the payload — write/stream {@link bytes}.
42
+ */
39
43
  content: string;
44
+ /**
45
+ * Present only for binary formats (e.g. `.docx`). When set, callers MUST
46
+ * write/stream these bytes rather than {@link content}.
47
+ */
48
+ bytes?: Uint8Array;
40
49
  }
41
50
 
42
51
  export type ExportOptions = {
@@ -47,6 +56,23 @@ export type ExportOptions = {
47
56
  const MD_MIME = 'text/markdown';
48
57
  const HTML_MIME = 'text/html';
49
58
 
59
+ /** Stand-in `content` for binary exports — the real payload rides in `bytes`. */
60
+ function docxNote(filename: string): string {
61
+ return `(Word 문서로 저장됨: ${filename})`;
62
+ }
63
+
64
+ /**
65
+ * Compose an export filename from slugged parts joined by `_`, then the
66
+ * extension — e.g. `["토스 백엔드 자기소개서", "홍길동"]` → `토스_백엔드_자기소개서_홍길동.docx`.
67
+ * Empty parts (no title, no owner name) drop out; if nothing survives, `fallback`
68
+ * is used. The owner's name as the trailing part makes files self-identifying in
69
+ * a downloads folder.
70
+ */
71
+ function exportName(parts: (string | null | undefined)[], ext: string, fallback: string): string {
72
+ const slug = parts.map((p) => slugify(p, '')).filter(Boolean).join('_') || fallback;
73
+ return `${slug}.${ext}`;
74
+ }
75
+
50
76
  /* ------------------------------------------------------------- cover letters */
51
77
 
52
78
  /** Build the shared markdown body for a cover letter (used by MD + HTML). */
@@ -74,7 +100,7 @@ export function coverLetterToMarkdown(
74
100
  opts?: ExportOptions,
75
101
  ): ExportResult {
76
102
  return {
77
- filename: `${slugify(cl.title, 'cover-letter')}.md`,
103
+ filename: exportName([cl.title, opts?.profile?.name], 'md', 'cover_letter'),
78
104
  mimeType: MD_MIME,
79
105
  content: coverLetterBody(cl, opts),
80
106
  };
@@ -86,12 +112,22 @@ export function coverLetterToHtml(
86
112
  opts?: ExportOptions,
87
113
  ): ExportResult {
88
114
  return {
89
- filename: `${slugify(cl.title, 'cover-letter')}.html`,
115
+ filename: exportName([cl.title, opts?.profile?.name], 'html', 'cover_letter'),
90
116
  mimeType: HTML_MIME,
91
117
  content: toPrintableHtml(cl.title, coverLetterBody(cl, opts)),
92
118
  };
93
119
  }
94
120
 
121
+ /** Cover letter → Word (.docx), ATS-friendly single column. */
122
+ export async function coverLetterToDocx(
123
+ cl: CoverLetterRecord,
124
+ opts?: ExportOptions,
125
+ ): Promise<ExportResult> {
126
+ const filename = exportName([cl.title, opts?.profile?.name], 'docx', 'cover_letter');
127
+ const bytes = await markdownToDocx(cl.title, coverLetterBody(cl, opts));
128
+ return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
129
+ }
130
+
95
131
  /* -------------------------------------------------------------------- resume */
96
132
 
97
133
  /** A stored resume/career document → Markdown. */
@@ -115,7 +151,7 @@ export function resumeToMarkdown(
115
151
  ]);
116
152
 
117
153
  return {
118
- filename: `${slugify(doc.title, doc.kind)}.md`,
154
+ filename: exportName([doc.title, profile?.name], 'md', doc.kind),
119
155
  mimeType: MD_MIME,
120
156
  content: body,
121
157
  };
@@ -128,12 +164,23 @@ export function resumeToHtml(
128
164
  ): ExportResult {
129
165
  const md = resumeToMarkdown(doc, profile);
130
166
  return {
131
- filename: `${slugify(doc.title, doc.kind)}.html`,
167
+ filename: exportName([doc.title, profile?.name], 'html', doc.kind),
132
168
  mimeType: HTML_MIME,
133
169
  content: toPrintableHtml(doc.title, md.content),
134
170
  };
135
171
  }
136
172
 
173
+ /** A stored resume/career document → Word (.docx). */
174
+ export async function resumeToDocx(
175
+ doc: DocumentRecord,
176
+ profile?: ProfileRecord | null,
177
+ ): Promise<ExportResult> {
178
+ const md = resumeToMarkdown(doc, profile);
179
+ const filename = exportName([doc.title, profile?.name], 'docx', doc.kind);
180
+ const bytes = await markdownToDocx(doc.title, md.content);
181
+ return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
182
+ }
183
+
137
184
  /* ------------------------------------------------------------------- profile */
138
185
 
139
186
  /** Full resume-style profile export → Markdown. */
@@ -243,7 +290,7 @@ export function profileToMarkdown(
243
290
  ]);
244
291
 
245
292
  return {
246
- filename: `${slugify(name, 'resume')}-resume.md`,
293
+ filename: exportName(['프로필', profile.name], 'md', 'resume'),
247
294
  mimeType: MD_MIME,
248
295
  content,
249
296
  };
@@ -259,12 +306,97 @@ export function profileToHtml(
259
306
  const md = profileToMarkdown(profile, experiences, projects, skills);
260
307
  const title = profile.name?.trim() || '이력서';
261
308
  return {
262
- filename: `${slugify(title, 'resume')}-resume.html`,
309
+ filename: exportName(['프로필', profile.name], 'html', 'resume'),
263
310
  mimeType: HTML_MIME,
264
311
  content: toPrintableHtml(title, md.content),
265
312
  };
266
313
  }
267
314
 
315
+ /** Full resume-style profile export → Word (.docx). */
316
+ export async function profileToDocx(
317
+ profile: ProfileRecord,
318
+ experiences: ExperienceRecord[] = [],
319
+ projects: ProjectRecord[] = [],
320
+ skills: SkillRecord[] = [],
321
+ ): Promise<ExportResult> {
322
+ const md = profileToMarkdown(profile, experiences, projects, skills);
323
+ const title = profile.name?.trim() || '이력서';
324
+ const filename = exportName(['프로필', profile.name], 'docx', 'resume');
325
+ const bytes = await markdownToDocx(title, md.content);
326
+ return { filename, mimeType: DOCX_MIME, content: docxNote(filename), bytes };
327
+ }
328
+
329
+ /**
330
+ * Full profile → JSON Resume (jsonresume.org, schema v1.0.0). Makes CareerMate
331
+ * data portable: the open standard is consumed by 100+ themes (HTML/PDF render)
332
+ * and other AI/ATS tools. Dates pass through as stored (YYYY-MM[-DD]); only
333
+ * populated fields are emitted to keep the document clean.
334
+ */
335
+ export function profileToJsonResume(
336
+ profile: ProfileRecord,
337
+ experiences: ExperienceRecord[] = [],
338
+ projects: ProjectRecord[] = [],
339
+ skills: SkillRecord[] = [],
340
+ ): ExportResult {
341
+ const clean = <T extends Record<string, unknown>>(o: T): T =>
342
+ Object.fromEntries(
343
+ Object.entries(o).filter(([, v]) => v != null && !(Array.isArray(v) && v.length === 0) && v !== ''),
344
+ ) as T;
345
+
346
+ const basics = clean({
347
+ name: profile.name ?? '',
348
+ label: profile.headline ?? '',
349
+ email: profile.email ?? '',
350
+ phone: profile.phone ?? '',
351
+ summary: profile.summary ?? '',
352
+ location: profile.location ? { address: profile.location } : undefined,
353
+ profiles: (profile.links ?? [])
354
+ .filter((l) => l.url)
355
+ .map((l) => clean({ network: l.label ?? '', url: l.url })),
356
+ });
357
+
358
+ const work = experiences.map((e) =>
359
+ clean({
360
+ name: e.company ?? '',
361
+ position: e.role ?? '',
362
+ startDate: e.start_date ?? '',
363
+ endDate: e.is_current ? '' : (e.end_date ?? ''),
364
+ summary: e.description ?? '',
365
+ highlights: e.achievements ?? [],
366
+ }),
367
+ );
368
+
369
+ const projectsOut = projects.map((p) =>
370
+ clean({
371
+ name: p.name ?? '',
372
+ description: p.description ?? '',
373
+ highlights: p.highlights ?? [],
374
+ keywords: p.tech ?? [],
375
+ url: p.url ?? '',
376
+ startDate: p.start_date ?? '',
377
+ endDate: p.end_date ?? '',
378
+ roles: p.role ? [p.role] : [],
379
+ }),
380
+ );
381
+
382
+ const skillsOut = skills.map((s) => clean({ name: s.name, level: s.level ?? '' }));
383
+
384
+ const resume = {
385
+ $schema: 'https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json',
386
+ basics,
387
+ work,
388
+ projects: projectsOut,
389
+ skills: skillsOut,
390
+ meta: { canonical: 'https://jsonresume.org', version: 'v1.0.0', generatedBy: 'CareerMate' },
391
+ };
392
+
393
+ return {
394
+ filename: exportName(['프로필', profile.name], 'json', 'resume'),
395
+ mimeType: 'application/json',
396
+ content: JSON.stringify(resume, null, 2),
397
+ };
398
+ }
399
+
268
400
  /* ------------------------------------------------------------ interview prep */
269
401
 
270
402
  /** Interview prep record → Markdown study sheet. */
@@ -343,7 +475,7 @@ export function interviewPrepToMarkdown(
343
475
  ]);
344
476
 
345
477
  return {
346
- filename: `${slugify(titleBase, 'interview-prep')}.md`,
478
+ filename: exportName([titleBase], 'md', 'interview_prep'),
347
479
  mimeType: MD_MIME,
348
480
  content,
349
481
  };
@@ -357,7 +489,7 @@ export function interviewPrepToHtml(
357
489
  const md = interviewPrepToMarkdown(prep, job);
358
490
  const title = job ? `${job.company} ${job.position} 면접 준비` : '면접 준비';
359
491
  return {
360
- filename: `${slugify(title, 'interview-prep')}.html`,
492
+ filename: exportName([title], 'html', 'interview_prep'),
361
493
  mimeType: HTML_MIME,
362
494
  content: toPrintableHtml(title, md.content),
363
495
  };
@@ -373,3 +505,4 @@ export {
373
505
  markdownToHtml,
374
506
  slugify,
375
507
  } from './markdown.ts';
508
+ export { DOCX_MIME } from './docx.ts';
@@ -11,19 +11,24 @@ import { escapeHtml } from './html.ts';
11
11
  /* ----------------------------------------------------------- slug / filenames */
12
12
 
13
13
  /**
14
- * Make a filesystem-safe, ASCII-leaning slug from arbitrary (often Korean) text.
15
- * Non-ASCII runs collapse to `-`; the result is lowercased and trimmed of
16
- * leading/trailing separators. Falls back to `document` when nothing survives.
14
+ * Make a filesystem-safe slug from arbitrary text, PRESERVING the original
15
+ * script (Hangul, CJK, Latin, digits) so a Korean title like "백엔드 자기소개서"
16
+ * yields "백엔드_자기소개서" instead of a generic fallback. Spaces and any
17
+ * characters that aren't Unicode letters/numbers (punctuation, path separators)
18
+ * collapse to `_`; the result is trimmed and capped to a sane filename length.
19
+ * Falls back to `fallback` when nothing survives (e.g. emoji-only titles); pass
20
+ * `''` to opt out of the fallback when composing a multi-part name.
21
+ * Unicode filenames are safe on modern OSes, and the download header
22
+ * (`http.ts` `sendDownload`) already allows Hangul and encodes UTF-8.
17
23
  */
18
24
  export function slugify(input: string | null | undefined, fallback = 'document'): string {
19
- const raw = (input ?? '').normalize('NFKD');
20
- // Keep ASCII alphanumerics; everything else becomes a separator.
21
- const slug = raw
22
- .replace(/[^\x00-\x7F]+/g, '-') // drop non-ASCII (e.g. Hangul) -> separator
23
- .replace(/[^a-zA-Z0-9]+/g, '-') // remaining punctuation/space -> separator
24
- .replace(/-+/g, '-')
25
- .replace(/^-+|-+$/g, '')
26
- .toLowerCase();
25
+ const slug = (input ?? '')
26
+ .normalize('NFC')
27
+ .replace(/[^\p{L}\p{N}]+/gu, '_') // non letter/number (space, punctuation, /\:*?…) -> separator
28
+ .replace(/_+/g, '_')
29
+ .replace(/^_+|_+$/g, '')
30
+ .slice(0, 80)
31
+ .replace(/_+$/g, ''); // re-trim a separator left dangling by the length cap
27
32
  return slug || fallback;
28
33
  }
29
34
 
@@ -47,7 +47,9 @@ export const EXPERT_DOMAINS = [
47
47
  ] as const;
48
48
  export type ExpertDomain = (typeof EXPERT_DOMAINS)[number];
49
49
 
50
- /** 6 verifier rubrics — 1:1 with docs/career-os/knowledge/verifiers/<id>.md (CONTRACT C3). */
50
+ /** 7 verifier rubrics — 1:1 with docs/career-os/knowledge/verifiers/<id>.md (CONTRACT C3).
51
+ * ai-detection-risk: additive (한국 AI-자소서 탐지 시장 전용 — 진정성·면접 방어가능성 점검,
52
+ * 탐지기 우회가 아님). human-voice(문체 "AI 티")와 역할이 다르다. */
51
53
  export const VERIFIER_IDS = [
52
54
  'truthfulness',
53
55
  'consistency',
@@ -55,6 +57,7 @@ export const VERIFIER_IDS = [
55
57
  'responsiveness-on-target',
56
58
  'ats-compat',
57
59
  'human-voice',
60
+ 'ai-detection-risk',
58
61
  ] as const;
59
62
  export type VerifierId = (typeof VERIFIER_IDS)[number];
60
63
 
@@ -121,13 +124,15 @@ export const CAREER_ROUTES: Record<string, CareerRoute> = {
121
124
  write_cover_letter: {
122
125
  eop: 'cover-letter',
123
126
  expertSequence: ['cover-letter', 'human-writing', 'company-research'],
124
- verifierSequence: ['human-voice', 'truthfulness', 'responsiveness-on-target', 'ats-compat', 'consistency'],
127
+ // ai-detection-risk: 한국 자소서는 AI 탐지(무하유 GPT킬러 등)가 제도화돼 있어, 저장 전
128
+ // "진정성·면접 방어가능성·자기복제 여부"를 점검한다(탐지 우회가 아님 — 짝은 human-voice).
129
+ verifierSequence: ['human-voice', 'truthfulness', 'responsiveness-on-target', 'ats-compat', 'consistency', 'ai-detection-risk'],
125
130
  loop: 'draft_verify_revise',
126
131
  },
127
132
  write_career_description: {
128
133
  eop: 'career-description',
129
134
  expertSequence: ['resume', 'ats'],
130
- verifierSequence: ['truthfulness', 'consistency', 'ats-compat'],
135
+ verifierSequence: ['truthfulness', 'consistency', 'ats-compat', 'ai-detection-risk'],
131
136
  loop: 'draft_verify_revise',
132
137
  },
133
138
  prepare_interview: {